@ably/ai-transport 0.0.1 → 0.1.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 (110) hide show
  1. package/README.md +54 -47
  2. package/dist/ably-ai-transport.js +1006 -539
  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 +4 -0
  7. package/dist/core/codec/types.d.ts +19 -2
  8. package/dist/core/transport/decode-history.d.ts +8 -6
  9. package/dist/core/transport/headers.d.ts +4 -2
  10. package/dist/core/transport/index.d.ts +4 -1
  11. package/dist/core/transport/pipe-stream.d.ts +3 -2
  12. package/dist/core/transport/stream-router.d.ts +11 -1
  13. package/dist/core/transport/tree.d.ts +171 -0
  14. package/dist/core/transport/turn-manager.d.ts +4 -1
  15. package/dist/core/transport/types.d.ts +270 -119
  16. package/dist/core/transport/view.d.ts +166 -0
  17. package/dist/errors.d.ts +19 -2
  18. package/dist/index.d.ts +3 -1
  19. package/dist/react/ably-ai-transport-react.js +1019 -486
  20. package/dist/react/ably-ai-transport-react.js.map +1 -1
  21. package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
  22. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
  23. package/dist/react/contexts/transport-context.d.ts +31 -0
  24. package/dist/react/contexts/transport-provider.d.ts +49 -0
  25. package/dist/react/create-transport-hooks.d.ts +124 -0
  26. package/dist/react/index.d.ts +14 -8
  27. package/dist/react/use-ably-messages.d.ts +14 -8
  28. package/dist/react/use-active-turns.d.ts +7 -3
  29. package/dist/react/use-client-transport.d.ts +78 -5
  30. package/dist/react/use-create-view.d.ts +22 -0
  31. package/dist/react/use-tree.d.ts +20 -0
  32. package/dist/react/use-view.d.ts +79 -0
  33. package/dist/vercel/ably-ai-transport-vercel.js +1478 -842
  34. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
  35. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
  36. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
  37. package/dist/vercel/codec/tool-transitions.d.ts +50 -0
  38. package/dist/vercel/index.d.ts +3 -0
  39. package/dist/vercel/react/ably-ai-transport-vercel-react.js +9099 -852
  40. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
  41. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +45 -1
  42. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
  43. package/dist/vercel/react/contexts/chat-transport-context.d.ts +32 -0
  44. package/dist/vercel/react/contexts/chat-transport-provider.d.ts +84 -0
  45. package/dist/vercel/react/index.d.ts +5 -0
  46. package/dist/vercel/react/use-chat-transport.d.ts +61 -20
  47. package/dist/vercel/react/use-message-sync.d.ts +41 -9
  48. package/dist/vercel/react/use-staged-add-tool-approval-response.d.ts +30 -0
  49. package/dist/vercel/tool-approvals.d.ts +124 -0
  50. package/dist/vercel/tool-events.d.ts +26 -0
  51. package/dist/vercel/transport/chat-transport.d.ts +33 -11
  52. package/dist/vercel/transport/index.d.ts +5 -2
  53. package/package.json +23 -17
  54. package/src/constants.ts +6 -0
  55. package/src/core/codec/encoder.ts +10 -1
  56. package/src/core/codec/types.ts +19 -3
  57. package/src/core/transport/client-transport.ts +382 -364
  58. package/src/core/transport/decode-history.ts +229 -81
  59. package/src/core/transport/headers.ts +6 -2
  60. package/src/core/transport/index.ts +13 -5
  61. package/src/core/transport/pipe-stream.ts +8 -5
  62. package/src/core/transport/server-transport.ts +212 -58
  63. package/src/core/transport/stream-router.ts +21 -3
  64. package/src/core/transport/{conversation-tree.ts → tree.ts} +192 -77
  65. package/src/core/transport/turn-manager.ts +28 -10
  66. package/src/core/transport/types.ts +318 -139
  67. package/src/core/transport/view.ts +840 -0
  68. package/src/errors.ts +21 -1
  69. package/src/index.ts +10 -5
  70. package/src/react/contexts/transport-context.ts +37 -0
  71. package/src/react/contexts/transport-provider.tsx +164 -0
  72. package/src/react/create-transport-hooks.ts +144 -0
  73. package/src/react/index.ts +15 -8
  74. package/src/react/use-ably-messages.ts +34 -16
  75. package/src/react/use-active-turns.ts +28 -17
  76. package/src/react/use-client-transport.ts +184 -24
  77. package/src/react/use-create-view.ts +68 -0
  78. package/src/react/use-tree.ts +53 -0
  79. package/src/react/use-view.ts +233 -0
  80. package/src/react/vite.config.ts +4 -1
  81. package/src/vercel/codec/accumulator.ts +64 -79
  82. package/src/vercel/codec/decoder.ts +11 -8
  83. package/src/vercel/codec/encoder.ts +68 -54
  84. package/src/vercel/codec/index.ts +0 -2
  85. package/src/vercel/codec/tool-transitions.ts +122 -0
  86. package/src/vercel/index.ts +17 -0
  87. package/src/vercel/react/contexts/chat-transport-context.ts +40 -0
  88. package/src/vercel/react/contexts/chat-transport-provider.tsx +122 -0
  89. package/src/vercel/react/index.ts +14 -0
  90. package/src/vercel/react/use-chat-transport.ts +164 -42
  91. package/src/vercel/react/use-message-sync.ts +77 -19
  92. package/src/vercel/react/use-staged-add-tool-approval-response.ts +87 -0
  93. package/src/vercel/react/vite.config.ts +4 -2
  94. package/src/vercel/tool-approvals.ts +380 -0
  95. package/src/vercel/tool-events.ts +53 -0
  96. package/src/vercel/transport/chat-transport.ts +225 -79
  97. package/src/vercel/transport/index.ts +14 -3
  98. package/dist/core/transport/conversation-tree.d.ts +0 -9
  99. package/dist/react/use-conversation-tree.d.ts +0 -20
  100. package/dist/react/use-edit.d.ts +0 -7
  101. package/dist/react/use-history.d.ts +0 -19
  102. package/dist/react/use-messages.d.ts +0 -7
  103. package/dist/react/use-regenerate.d.ts +0 -7
  104. package/dist/react/use-send.d.ts +0 -7
  105. package/src/react/use-conversation-tree.ts +0 -71
  106. package/src/react/use-edit.ts +0 -24
  107. package/src/react/use-history.ts +0 -111
  108. package/src/react/use-messages.ts +0 -32
  109. package/src/react/use-regenerate.ts +0 -24
  110. package/src/react/use-send.ts +0 -25
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * decodeHistory — load conversation history from an Ably channel and
3
- * return decoded messages as a PaginatedMessages result.
3
+ * return decoded messages as a paginated HistoryPage result.
4
4
  *
5
5
  * Uses a fresh decoder (not shared with the live subscription) to avoid
6
6
  * state conflicts. Per-turn accumulators handle interleaved turns correctly.
@@ -16,17 +16,26 @@
16
16
  *
17
17
  * Because Ably history returns newest-first while the decoder requires
18
18
  * chronological order, all collected Ably messages are re-decoded from
19
- * oldest to newest after each page fetch. This handles turns that span
20
- * page boundaries correctly.
19
+ * oldest to newest at the point a result is built. This handles turns
20
+ * that span page boundaries correctly. The fetch loop uses a cheap
21
+ * header-based completion counter to decide when to stop paging, so the
22
+ * full decode runs exactly once per traversal regardless of page count.
21
23
  */
22
24
 
23
25
  import type * as Ably from 'ably';
24
26
 
25
- import { HEADER_MSG_ID, HEADER_TURN_ID } from '../../constants.js';
27
+ import {
28
+ HEADER_AMEND,
29
+ HEADER_DISCRETE,
30
+ HEADER_MSG_ID,
31
+ HEADER_STATUS,
32
+ HEADER_STREAM,
33
+ HEADER_TURN_ID,
34
+ } from '../../constants.js';
26
35
  import type { Logger } from '../../logger.js';
27
36
  import { getHeaders } from '../../utils.js';
28
37
  import type { Codec, DecoderOutput, MessageAccumulator } from '../codec/types.js';
29
- import type { LoadHistoryOptions, PaginatedMessages } from './types.js';
38
+ import type { HistoryPage, LoadHistoryOptions } from './types.js';
30
39
 
31
40
  // ---------------------------------------------------------------------------
32
41
  // Shared state across pages within one history traversal
@@ -42,8 +51,36 @@ interface HistoryState<TEvent, TMessage> {
42
51
  returnedRawCount: number;
43
52
  /** The last Ably page cursor for continued pagination. */
44
53
  lastAblyPage: Ably.PaginatedResult<Ably.InboundMessage> | undefined;
45
- /** Key function for domain messages (codec.getMessageKey). */
46
- getMessageKey: (message: TMessage) => string;
54
+ /**
55
+ * Cached result of the last {@link decodeAll} call, reused while
56
+ * `rawMessages` is unchanged. Invalidated implicitly by comparing
57
+ * {@link cachedAtRawLength} against `rawMessages.length`; `rawMessages`
58
+ * is append-only within a traversal so length is a sufficient key.
59
+ */
60
+ cachedDecode: DecodedItem<TMessage>[] | undefined;
61
+ /** `rawMessages.length` at the time {@link cachedDecode} was produced. */
62
+ cachedAtRawLength: number;
63
+ /**
64
+ * `x-ably-msg-id`s for which the decoder has something to produce output
65
+ * from: any `message.create` / `message.update` / `message.append` with
66
+ * `x-ably-stream: "true"` (establishes a tracker via create or
67
+ * first-contact), or a `message.create` carrying `x-ably-discrete` (a
68
+ * discrete message, created and terminated in one wire message).
69
+ */
70
+ startedMsgIds: Set<string>;
71
+ /**
72
+ * `x-ably-msg-id`s with a terminal wire signal: either `x-ably-discrete`
73
+ * on a `message.create` (discrete message) or `x-ably-status: "finished"`
74
+ * / `"aborted"` on any action (closed stream).
75
+ */
76
+ terminatedMsgIds: Set<string>;
77
+ /**
78
+ * `x-ably-msg-id`s that are both started AND terminated - ready to appear
79
+ * in the decoded output. The fetch loop reads this set's size to decide
80
+ * when to stop paging, avoiding a full decode per page. Maintained
81
+ * incrementally by {@link countNewCompletions}. Grows monotonically.
82
+ */
83
+ completedMsgIds: Set<string>;
47
84
  logger: Logger;
48
85
  }
49
86
 
@@ -84,10 +121,16 @@ const decodeAll = <TEvent, TMessage>(state: HistoryState<TEvent, TMessage>): Dec
84
121
  const defaultAccumulator = state.codec.createAccumulator();
85
122
  let orderCounter = 0;
86
123
 
87
- // Headers for discrete messages (writeMessages output), keyed by codec message key.
124
+ // Headers and serials for non-turn discrete messages, keyed by x-ably-msg-id.
88
125
  const discreteHeaders = new Map<string, Record<string, string>>();
89
- // Serials for discrete messages, keyed by codec message key.
90
126
  const discreteSerials = new Map<string, string>();
127
+ // Track which msgId produced each non-turn discrete message output (in order).
128
+ const discreteMsgIds: string[] = [];
129
+
130
+ // Cross-turn event targets to complete after all events are processed.
131
+ // Deferred so that finish/abort events that follow the update in serial
132
+ // order can still process on the active message (e.g. applying messageMetadata).
133
+ const deferredCompletions: { accumulator: MessageAccumulator<TEvent, TMessage>; messageId: string }[] = [];
91
134
 
92
135
  for (const msg of chronological) {
93
136
  const outputs: DecoderOutput<TEvent, TMessage>[] = decoder.decode(msg);
@@ -95,6 +138,26 @@ const decodeAll = <TEvent, TMessage>(state: HistoryState<TEvent, TMessage>): Dec
95
138
  const turnId = headers[HEADER_TURN_ID];
96
139
  const msgId = headers[HEADER_MSG_ID];
97
140
  const serial = msg.serial;
141
+ const amendTarget = headers[HEADER_AMEND];
142
+
143
+ // Cross-turn events target an existing message from a different turn.
144
+ // Route to the owning turn's accumulator via initMessage lifecycle.
145
+ if (amendTarget) {
146
+ for (const turn of turns.values()) {
147
+ if (turn.msgHeaders.has(amendTarget)) {
148
+ const headerKeys = [...turn.msgHeaders.keys()];
149
+ const msgIndex = headerKeys.indexOf(amendTarget);
150
+ const currentMsg = msgIndex === -1 ? undefined : turn.accumulator.messages[msgIndex];
151
+ if (currentMsg) {
152
+ turn.accumulator.initMessage(amendTarget, currentMsg);
153
+ }
154
+ turn.accumulator.processOutputs(outputs);
155
+ deferredCompletions.push({ accumulator: turn.accumulator, messageId: amendTarget });
156
+ break;
157
+ }
158
+ }
159
+ continue;
160
+ }
98
161
 
99
162
  if (turnId) {
100
163
  let turn = turns.get(turnId);
@@ -123,81 +186,65 @@ const decodeAll = <TEvent, TMessage>(state: HistoryState<TEvent, TMessage>): Dec
123
186
  turn.accumulator.processOutputs(outputs);
124
187
  } else {
125
188
  defaultAccumulator.processOutputs(outputs);
126
- }
127
189
 
128
- // Capture headers and serial for discrete messages by codec key.
129
- for (const output of outputs) {
130
- if (output.kind === 'message') {
131
- const key = state.getMessageKey(output.message);
132
- const existingDiscrete = discreteHeaders.get(key);
133
- if (!existingDiscrete) {
134
- discreteHeaders.set(key, { ...headers });
135
- if (serial) discreteSerials.set(key, serial);
136
- } else if (Object.keys(headers).length > 0) {
137
- Object.assign(existingDiscrete, headers);
190
+ // Capture headers and serial for non-turn discrete messages by x-ably-msg-id.
191
+ for (const output of outputs) {
192
+ if (output.kind === 'message' && msgId) {
193
+ discreteMsgIds.push(msgId);
194
+ const existingDiscrete = discreteHeaders.get(msgId);
195
+ if (!existingDiscrete) {
196
+ discreteHeaders.set(msgId, { ...headers });
197
+ if (serial) discreteSerials.set(msgId, serial);
198
+ } else if (Object.keys(headers).length > 0) {
199
+ Object.assign(existingDiscrete, headers);
200
+ }
138
201
  }
139
202
  }
140
203
  }
141
204
  }
142
205
 
206
+ // Complete any messages that were re-activated for cross-turn updates.
207
+ // Idempotent — if finish already removed the message from active tracking,
208
+ // completeMessage is a no-op.
209
+ for (const { accumulator, messageId } of deferredCompletions) {
210
+ accumulator.completeMessage(messageId);
211
+ }
212
+
143
213
  // Collect completed messages in chronological order (oldest first) by turn.
144
214
  const completed: DecodedItem<TMessage>[] = [];
145
215
 
146
- for (const msg of defaultAccumulator.completedMessages) {
147
- const key = state.getMessageKey(msg);
216
+ // Default accumulator messages: pair with their discrete headers by position.
217
+ for (const [i, msg] of defaultAccumulator.completedMessages.entries()) {
218
+ const mid = discreteMsgIds[i];
148
219
  completed.push({
149
220
  message: msg,
150
- headers: discreteHeaders.get(key) ?? {},
151
- serial: discreteSerials.get(key) ?? '',
221
+ headers: mid ? (discreteHeaders.get(mid) ?? {}) : {},
222
+ serial: mid ? (discreteSerials.get(mid) ?? '') : '',
152
223
  });
153
224
  }
154
225
 
155
226
  const sorted = [...turns.values()].toSorted((a, b) => a.firstSeen - b.firstSeen);
156
227
  for (const turn of sorted) {
157
228
  // Assign headers and serials to each completed message in this turn.
158
- // Discrete messages were already captured by codec key. Accumulated
159
- // messages need to be matched to the turn's per-msg-id headers.
160
- const claimedMsgIds = new Set<string>();
161
-
162
- // First pass: resolve discrete messages and mark their msg-ids as claimed
163
- const turnKeyHeaders = new Map<string, Record<string, string>>();
164
- const turnKeySerials = new Map<string, string>();
165
- for (const msg of turn.accumulator.completedMessages) {
166
- const key = state.getMessageKey(msg);
167
- const discrete = discreteHeaders.get(key);
168
- if (discrete) {
169
- turnKeyHeaders.set(key, discrete);
170
- const dSerial = discreteSerials.get(key);
171
- if (dSerial) turnKeySerials.set(key, dSerial);
172
- const mid = discrete[HEADER_MSG_ID];
173
- if (mid) claimedMsgIds.add(mid);
174
- }
175
- }
176
-
177
- // Second pass: assign unclaimed msg-id entries to remaining messages
178
- const unclaimedEntries = [...turn.msgHeaders.entries()].filter(([mid]) => !claimedMsgIds.has(mid));
179
- let unclaimedIdx = 0;
229
+ // The turn's msgHeaders map is keyed by x-ably-msg-id and ordered by
230
+ // first-seen. Completed messages are matched positionally.
231
+ const headerEntries = [...turn.msgHeaders.entries()];
232
+ let headerIdx = 0;
180
233
 
181
234
  for (const msg of turn.accumulator.completedMessages) {
182
- const key = state.getMessageKey(msg);
183
- const unclaimed = unclaimedEntries[unclaimedIdx];
184
- if (!turnKeyHeaders.has(key) && unclaimed) {
185
- const [mid, hdrs] = unclaimed;
186
- turnKeyHeaders.set(key, hdrs);
187
- const mSerial = turn.msgSerials.get(mid);
188
- if (mSerial) turnKeySerials.set(key, mSerial);
189
- unclaimedIdx++;
235
+ const entry = headerEntries[headerIdx];
236
+ if (entry) {
237
+ const [mid, hdrs] = entry;
238
+ completed.push({
239
+ message: msg,
240
+ headers: hdrs,
241
+ serial: turn.msgSerials.get(mid) ?? '',
242
+ });
243
+ headerIdx++;
244
+ } else {
245
+ completed.push({ message: msg, headers: {}, serial: '' });
190
246
  }
191
247
  }
192
-
193
- for (const msg of turn.accumulator.completedMessages) {
194
- const key = state.getMessageKey(msg);
195
- completed.push({
196
- message: msg,
197
- headers: turnKeyHeaders.get(key) ?? {},
198
- serial: turnKeySerials.get(key) ?? '',
199
- });
200
- }
201
248
  }
202
249
 
203
250
  // Reverse to newest-first. The consumer slices from the front for the
@@ -205,12 +252,113 @@ const decodeAll = <TEvent, TMessage>(state: HistoryState<TEvent, TMessage>): Dec
205
252
  return completed.toReversed();
206
253
  };
207
254
 
255
+ /**
256
+ * Cached wrapper around {@link decodeAll}. Returns the previous result when
257
+ * `rawMessages` hasn't changed since the last decode; otherwise re-decodes
258
+ * and updates the cache. The cache key is `rawMessages.length` because
259
+ * `rawMessages` is append-only within a traversal.
260
+ * @param state - The shared history traversal state.
261
+ * @returns Completed messages in newest-first order.
262
+ */
263
+ const decodeAllCached = <TEvent, TMessage>(state: HistoryState<TEvent, TMessage>): DecodedItem<TMessage>[] => {
264
+ if (state.cachedDecode && state.cachedAtRawLength === state.rawMessages.length) {
265
+ return state.cachedDecode;
266
+ }
267
+ const result = decodeAll(state);
268
+ state.cachedDecode = result;
269
+ state.cachedAtRawLength = state.rawMessages.length;
270
+ return result;
271
+ };
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // Incremental completion counting (avoids full decode inside the fetch loop)
275
+ // ---------------------------------------------------------------------------
276
+
277
+ /**
278
+ * Scan newly-added raw messages and track which `x-ably-msg-id`s have
279
+ * become complete. Used by {@link fetchUntilLimit} to decide when enough
280
+ * completed messages have been collected, without running the decoder.
281
+ *
282
+ * A msg-id is considered complete only when BOTH of these have been seen:
283
+ * - a "start" signal: either `x-ably-discrete` on a `message.create`
284
+ * (discrete messages are created and terminated by the same wire
285
+ * message), OR any `message.create` / `message.update` / `message.append`
286
+ * with `x-ably-stream: "true"` (the decoder establishes a tracker via
287
+ * create or first-contact).
288
+ * - a "terminal" signal: `x-ably-discrete` on the create, or
289
+ * `x-ably-status: "finished"` / `"aborted"` on any later action.
290
+ *
291
+ * Why update and append count as starts: Ably history can compact a live
292
+ * `create + append + ... + append{status:finished}` sequence into a single
293
+ * `message.update` with `STREAM=true` and `STATUS=finished`. The decoder
294
+ * handles that in {@link _decodeUpdate} via first-contact. Counting only
295
+ * `message.create` as a start would cause the fetch loop to page past a
296
+ * compacted turn without ever marking it complete.
297
+ *
298
+ * Requiring both halves matters when a streaming turn spans a page
299
+ * boundary: the terminal arrives in the newer page (fetched first) while
300
+ * the start sits in an older page. Counting the terminal alone would stop
301
+ * the fetch loop prematurely - the decoder would have no stream state to
302
+ * resolve, and the message wouldn't make it into the result.
303
+ *
304
+ * Messages skipped for counting:
305
+ * - Missing `x-ably-msg-id`: lifecycle events not tied to a domain message.
306
+ * - `x-ably-amend` set: amendments target an existing message, not a new
307
+ * completion.
308
+ * - `message.delete`: clears the tracker, doesn't produce output.
309
+ *
310
+ * Known edge case: if Ably history is truncated and a terminal survives
311
+ * while every start signal for its msg-id has rolled off, the counter will
312
+ * never mark that `msg-id` complete. The loop keeps fetching until it runs
313
+ * out of pages, then returns whatever the decoder actually produced.
314
+ * Matches the existing behaviour for the same truncation scenario.
315
+ * @param state - The shared history traversal state.
316
+ * @param newMessages - The Ably messages just pushed onto `state.rawMessages`.
317
+ */
318
+ const countNewCompletions = <TEvent, TMessage>(
319
+ state: HistoryState<TEvent, TMessage>,
320
+ newMessages: readonly Ably.InboundMessage[],
321
+ ): void => {
322
+ for (const msg of newMessages) {
323
+ const headers = getHeaders(msg);
324
+ const msgId = headers[HEADER_MSG_ID];
325
+ if (!msgId) continue;
326
+ // Amendments target an existing message, not a new completion.
327
+ // Defensive: no current encoder path produces an amendment carrying
328
+ // HEADER_STREAM=true, HEADER_STATUS, or HEADER_DISCRETE.
329
+ if (headers[HEADER_AMEND]) continue;
330
+
331
+ const action = msg.action;
332
+ const isDiscreteCreate = action === 'message.create' && HEADER_DISCRETE in headers;
333
+ // Any content-producing action on a streamed serial counts as a start:
334
+ // the decoder uses create or first-contact (update/append) to establish
335
+ // its tracker. Delete clears tracker state and emits nothing, so it
336
+ // never counts as a start.
337
+ const hasStreamContent =
338
+ headers[HEADER_STREAM] === 'true' &&
339
+ (action === 'message.create' || action === 'message.update' || action === 'message.append');
340
+ const status = headers[HEADER_STATUS];
341
+ const isTerminal = status === 'finished' || status === 'aborted';
342
+
343
+ if (isDiscreteCreate || hasStreamContent) state.startedMsgIds.add(msgId);
344
+ if (isDiscreteCreate || isTerminal) state.terminatedMsgIds.add(msgId);
345
+ if (state.startedMsgIds.has(msgId) && state.terminatedMsgIds.has(msgId)) {
346
+ state.completedMsgIds.add(msgId);
347
+ }
348
+ }
349
+ };
350
+
208
351
  // ---------------------------------------------------------------------------
209
352
  // Fetch Ably pages until we have enough completed messages
210
353
  // ---------------------------------------------------------------------------
211
354
 
212
355
  /**
213
356
  * Fetch Ably history pages until we have enough completed messages.
357
+ *
358
+ * The loop uses {@link countNewCompletions} to decide when to stop -
359
+ * a cheap O(new messages) header scan - rather than running the full
360
+ * decoder per page. The decoder runs exactly once later, in
361
+ * {@link buildResult}, against the fully-collected `rawMessages`.
214
362
  * @param state - The shared history traversal state.
215
363
  * @param ablyPage - The current Ably paginated result to start from.
216
364
  * @param limit - Target number of completed messages beyond what has already been returned.
@@ -222,39 +370,37 @@ const fetchUntilLimit = async <TEvent, TMessage>(
222
370
  ): Promise<void> => {
223
371
  state.rawMessages.push(...ablyPage.items);
224
372
  state.lastAblyPage = ablyPage;
373
+ countNewCompletions(state, ablyPage.items);
225
374
 
226
- let decodedCount = decodeAll(state).length;
227
- while (decodedCount < state.returnedCount + limit && ablyPage.hasNext()) {
375
+ const target = state.returnedCount + limit;
376
+ while (state.completedMsgIds.size < target && ablyPage.hasNext()) {
228
377
  state.logger.debug('decodeHistory.fetchUntilLimit(); fetching next page', {
229
378
  collected: state.rawMessages.length,
230
- decoded: decodedCount,
379
+ completed: state.completedMsgIds.size,
231
380
  });
232
381
  const nextPage = await ablyPage.next();
233
382
  if (!nextPage) break;
234
383
  ablyPage = nextPage;
235
384
  state.rawMessages.push(...nextPage.items);
236
385
  state.lastAblyPage = nextPage;
237
- decodedCount = decodeAll(state).length;
386
+ countNewCompletions(state, nextPage.items);
238
387
  }
239
388
  };
240
389
 
241
390
  // ---------------------------------------------------------------------------
242
- // Build PaginatedMessages result from current state
391
+ // Build HistoryPage result from current state
243
392
  // ---------------------------------------------------------------------------
244
393
 
245
394
  /**
246
- * Build a PaginatedMessages page from the current decode state.
395
+ * Build a HistoryPage from the current decode state.
247
396
  * @param state - The shared history traversal state.
248
397
  * @param limit - Max messages per page.
249
- * @returns A page of decoded messages with a `next()` cursor.
398
+ * @returns A page of decoded history with a `next()` cursor.
250
399
  */
251
- const buildResult = <TEvent, TMessage>(
252
- state: HistoryState<TEvent, TMessage>,
253
- limit: number,
254
- ): PaginatedMessages<TMessage> => {
400
+ const buildResult = <TEvent, TMessage>(state: HistoryState<TEvent, TMessage>, limit: number): HistoryPage<TMessage> => {
255
401
  // allCompleted is newest-first. Slice from returnedCount for this page,
256
402
  // then reverse to chronological for display.
257
- const allCompleted = decodeAll(state);
403
+ const allCompleted = decodeAllCached(state);
258
404
 
259
405
  const pageSlice = allCompleted.slice(state.returnedCount, state.returnedCount + limit);
260
406
  const chronSlice = [...pageSlice].toReversed();
@@ -269,9 +415,7 @@ const buildResult = <TEvent, TMessage>(
269
415
  state.returnedRawCount = state.rawMessages.length;
270
416
 
271
417
  return {
272
- items: chronSlice.map((d) => d.message),
273
- itemHeaders: chronSlice.map((d) => d.headers),
274
- itemSerials: chronSlice.map((d) => d.serial),
418
+ items: chronSlice.map((d) => ({ message: d.message, headers: d.headers, serial: d.serial })),
275
419
  rawMessages: rawSlice,
276
420
  hasNext: () => moreCompleted || moreAblyPages,
277
421
  next: async () => {
@@ -304,7 +448,7 @@ const buildResult = <TEvent, TMessage>(
304
448
  * @param codec - The codec for decoding wire messages into domain messages.
305
449
  * @param options - Pagination options.
306
450
  * @param logger - Logger for diagnostic output.
307
- * @returns The first page of decoded history messages.
451
+ * @returns The first page of decoded history.
308
452
  */
309
453
  // Spec: AIT-CT11, AIT-CT11b
310
454
  export const decodeHistory = async <TEvent, TMessage>(
@@ -312,7 +456,7 @@ export const decodeHistory = async <TEvent, TMessage>(
312
456
  codec: Codec<TEvent, TMessage>,
313
457
  options: LoadHistoryOptions | undefined,
314
458
  logger: Logger,
315
- ): Promise<PaginatedMessages<TMessage>> => {
459
+ ): Promise<HistoryPage<TMessage>> => {
316
460
  const limit = options?.limit ?? 100;
317
461
  const state: HistoryState<TEvent, TMessage> = {
318
462
  codec,
@@ -320,7 +464,11 @@ export const decodeHistory = async <TEvent, TMessage>(
320
464
  returnedCount: 0,
321
465
  returnedRawCount: 0,
322
466
  lastAblyPage: undefined,
323
- getMessageKey: codec.getMessageKey.bind(codec),
467
+ cachedDecode: undefined,
468
+ cachedAtRawLength: 0,
469
+ startedMsgIds: new Set<string>(),
470
+ terminatedMsgIds: new Set<string>(),
471
+ completedMsgIds: new Set<string>(),
324
472
  logger,
325
473
  };
326
474
 
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import {
10
+ HEADER_AMEND,
10
11
  HEADER_FORK_OF,
11
12
  HEADER_MSG_ID,
12
13
  HEADER_PARENT,
@@ -22,8 +23,9 @@ import {
22
23
  * @param opts.turnId - Turn correlation ID.
23
24
  * @param opts.msgId - Message identity.
24
25
  * @param opts.turnClientId - ClientId of the turn initiator.
25
- * @param opts.parent - Preceding message's msg-id (for branching). Null means root.
26
+ * @param opts.parent - Preceding message's msg-id (for branching).
26
27
  * @param opts.forkOf - Forked message's msg-id (for edit/regen).
28
+ * @param opts.amend - The msg-id of the existing message this message targets (cross-turn events).
27
29
  * @returns A headers record with the `x-ably-*` transport headers set.
28
30
  */
29
31
  export const buildTransportHeaders = (opts: {
@@ -31,8 +33,9 @@ export const buildTransportHeaders = (opts: {
31
33
  turnId: string;
32
34
  msgId: string;
33
35
  turnClientId?: string;
34
- parent?: string | null;
36
+ parent?: string;
35
37
  forkOf?: string;
38
+ amend?: string;
36
39
  }): Record<string, string> => {
37
40
  const h: Record<string, string> = {
38
41
  [HEADER_ROLE]: opts.role,
@@ -42,5 +45,6 @@ export const buildTransportHeaders = (opts: {
42
45
  if (opts.turnClientId !== undefined) h[HEADER_TURN_CLIENT_ID] = opts.turnClientId;
43
46
  if (opts.parent) h[HEADER_PARENT] = opts.parent;
44
47
  if (opts.forkOf) h[HEADER_FORK_OF] = opts.forkOf;
48
+ if (opts.amend) h[HEADER_AMEND] = opts.amend;
45
49
  return h;
46
50
  };
@@ -8,22 +8,30 @@ export type {
8
8
  ClientTransport,
9
9
  ClientTransportOptions,
10
10
  CloseOptions,
11
- ConversationNode,
12
- ConversationTree,
13
- LoadHistoryOptions,
14
- MessageWithHeaders,
11
+ EventsNode,
12
+ MessageNode,
15
13
  NewTurnOptions,
16
- PaginatedMessages,
17
14
  SendOptions,
18
15
  ServerTransport,
19
16
  ServerTransportOptions,
20
17
  StreamResponseOptions,
21
18
  StreamResult,
19
+ Tree,
22
20
  Turn,
23
21
  TurnEndReason,
24
22
  TurnLifecycleEvent,
23
+ View,
25
24
  } from './types.js';
26
25
 
26
+ // Deprecated aliases — intentional re-export of deprecated types for backwards compatibility.
27
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
28
+ export type { EventNode } from './types.js';
29
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
30
+ export type { TreeNode } from './types.js';
31
+
32
+ // Internal tree interface (consumed by View implementations)
33
+ export type { TreeInternal } from './tree.js';
34
+
27
35
  // Server transport
28
36
  export { createServerTransport } from './server-transport.js';
29
37
 
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { Logger } from '../../logger.js';
9
- import type { StreamEncoder } from '../codec/types.js';
9
+ import type { StreamEncoder, WriteOptions } from '../codec/types.js';
10
10
  import type { StreamResult } from './types.js';
11
11
 
12
12
  /**
@@ -18,6 +18,7 @@ import type { StreamResult } from './types.js';
18
18
  * @param encoder - The streaming encoder to write events through.
19
19
  * @param signal - Abort signal to monitor for cancellation.
20
20
  * @param onAbort - Optional callback invoked when the stream is cancelled, before the stream ends.
21
+ * @param resolveWriteOptions - Optional per-event hook returning {@link WriteOptions} overrides to pass to `encoder.appendEvent`.
21
22
  * @param logger - Optional logger for diagnostic output.
22
23
  * @returns The reason the pipe ended.
23
24
  */
@@ -26,6 +27,7 @@ export const pipeStream = async <TEvent, TMessage>(
26
27
  encoder: StreamEncoder<TEvent, TMessage>,
27
28
  signal: AbortSignal | undefined,
28
29
  onAbort?: (write: (event: TEvent) => Promise<void>) => void | Promise<void>,
30
+ resolveWriteOptions?: (event: TEvent) => WriteOptions | undefined,
29
31
  logger?: Logger,
30
32
  ): Promise<StreamResult> => {
31
33
  logger?.trace('pipeStream();');
@@ -48,6 +50,7 @@ export const pipeStream = async <TEvent, TMessage>(
48
50
  new Promise<void>(() => {});
49
51
 
50
52
  let reason: StreamResult['reason'] = 'complete';
53
+ let caughtError: Error | undefined;
51
54
 
52
55
  try {
53
56
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentional infinite loop broken by return/break
@@ -73,12 +76,12 @@ export const pipeStream = async <TEvent, TMessage>(
73
76
  break;
74
77
  }
75
78
 
76
- await encoder.appendEvent(value);
79
+ await encoder.appendEvent(value, resolveWriteOptions?.(value));
77
80
  }
78
81
  } catch (error) {
79
82
  reason = 'error';
80
- const errorText = error instanceof Error ? error.message : String(error);
81
- logger?.error('pipeStream(); stream error', { error: errorText });
83
+ caughtError = error instanceof Error ? error : new Error(String(error));
84
+ logger?.error('pipeStream(); stream error', { error: caughtError.message });
82
85
  try {
83
86
  await encoder.close();
84
87
  } catch {
@@ -91,5 +94,5 @@ export const pipeStream = async <TEvent, TMessage>(
91
94
  reader.releaseLock();
92
95
  }
93
96
 
94
- return { reason };
97
+ return { reason, error: caughtError };
95
98
  };