@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
@@ -0,0 +1,220 @@
1
+ /**
2
+ * loadHistoryPages — shared low-level history pagination primitive.
3
+ *
4
+ * Consumed by both client (via `load-history.ts`, which layers a complete-
5
+ * domain-message counter on top) and agent (directly, for input-event lookup
6
+ * and conversation hydration). Returns raw Ably messages; does NOT decode.
7
+ *
8
+ * Behaviour:
9
+ * - Attaches the channel (idempotent) then pages via `channel.history()`,
10
+ * using `untilAttach: true` for gapless continuity with any live subscription.
11
+ * - Exposes the underlying pagination as a cursor with `hasNext()` (cheap,
12
+ * no network) and `next()` (one Ably page per call, newest-first within
13
+ * the page).
14
+ * - Per-page failures are retried with bounded exponential backoff; on
15
+ * exhaustion throws `Ably.ErrorInfo` with code `HistoryFetchFailed`.
16
+ * - `signal.aborted` is checked between pages; rejects with
17
+ * `Ably.ErrorInfo` (InvalidArgument) when aborted.
18
+ *
19
+ * Spec: AIT-CT11 / AIT-ST hydration.
20
+ */
21
+
22
+ import * as Ably from 'ably';
23
+
24
+ import { ErrorCode } from '../../errors.js';
25
+ import type { Logger } from '../../logger.js';
26
+ import { errorCause, errorMessage } from '../../utils.js';
27
+
28
+ /** Options for {@link loadHistoryPages}. */
29
+ export interface LoadHistoryPagesOptions {
30
+ /** Wire-message limit per Ably page. */
31
+ pageLimit: number;
32
+ /** Set `untilAttach: true` on the underlying history query for gapless continuity with live subscriptions. Default: true. */
33
+ untilAttach?: boolean;
34
+ /** AbortSignal checked between pages. Rejects with InvalidArgument when aborted. */
35
+ signal?: AbortSignal;
36
+ /** Max retries per `page.next()` / initial `history()` failure. Default: 3. */
37
+ maxRetries?: number;
38
+ /** Initial retry backoff in ms (doubled per attempt). Default: 100. */
39
+ retryBackoffMs?: number;
40
+ /** Logger for diagnostic output. */
41
+ logger?: Logger;
42
+ }
43
+
44
+ /**
45
+ * Cursor over the channel's history pages.
46
+ *
47
+ * `hasNext()` is cheap (cursor-only, no network); `next()` issues one Ably
48
+ * page fetch (with retry/backoff) and returns its messages. Once `next()`
49
+ * returns `undefined` the cursor is exhausted.
50
+ */
51
+ export interface HistoryPagesCursor {
52
+ /** True when another Ably page is available (cheap to check; no network). */
53
+ hasNext(): boolean;
54
+ /**
55
+ * Fetch the next Ably page's messages (newest-first within the page).
56
+ * Returns `undefined` when no more pages are available or the abort
57
+ * signal has fired.
58
+ */
59
+ next(): Promise<readonly Ably.InboundMessage[] | undefined>;
60
+ }
61
+
62
+ /**
63
+ * Sleep for `ms` milliseconds, honouring an AbortSignal.
64
+ * @param ms - Milliseconds to wait.
65
+ * @param signal - Optional abort signal; rejects when fired.
66
+ */
67
+ // eslint-disable-next-line @typescript-eslint/promise-function-async -- the function body is the Promise constructor; async would wrap it in an extra Promise
68
+ const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
69
+ new Promise<void>((resolve, reject) => {
70
+ if (signal?.aborted) {
71
+ reject(new Ably.ErrorInfo('unable to wait; signal aborted', ErrorCode.InvalidArgument, 400));
72
+ return;
73
+ }
74
+ const timer: ReturnType<typeof setTimeout> | number = setTimeout(() => {
75
+ signal?.removeEventListener('abort', onAbort);
76
+ resolve();
77
+ }, ms);
78
+ // Node returns an unref-able Timeout; browsers return a number. Unref so
79
+ // a retry backoff cannot keep a Node process alive by itself.
80
+ if (typeof timer === 'object') timer.unref();
81
+ const onAbort = (): void => {
82
+ clearTimeout(timer);
83
+ reject(new Ably.ErrorInfo('unable to wait; signal aborted', ErrorCode.InvalidArgument, 400));
84
+ };
85
+ signal?.addEventListener('abort', onAbort, { once: true });
86
+ });
87
+
88
+ /**
89
+ * Invoke `fetchPage`, retrying on failure with exponential backoff. Throws
90
+ * the last failure wrapped as `HistoryFetchFailed` once retries are
91
+ * exhausted.
92
+ * @param fetchPage - The page fetch to retry (initial `channel.history()` call or a `page.next()`).
93
+ * @param maxRetries - Maximum number of attempts after the initial call.
94
+ * @param initialBackoffMs - Starting backoff delay (doubled per attempt).
95
+ * @param signal - Optional abort signal; cancels remaining retries.
96
+ * @param logger - Optional logger.
97
+ * @returns The fetched page, or `undefined` when pagination is exhausted.
98
+ */
99
+ const fetchPageWithRetry = async (
100
+ fetchPage: () => Promise<Ably.PaginatedResult<Ably.InboundMessage> | undefined>,
101
+ maxRetries: number,
102
+ initialBackoffMs: number,
103
+ signal: AbortSignal | undefined,
104
+ logger: Logger | undefined,
105
+ ): Promise<Ably.PaginatedResult<Ably.InboundMessage> | undefined> => {
106
+ let lastError: unknown;
107
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
108
+ if (signal?.aborted) {
109
+ throw new Ably.ErrorInfo(
110
+ 'unable to fetch history page; signal aborted',
111
+ ErrorCode.InvalidArgument,
112
+ 400,
113
+ errorCause(lastError),
114
+ );
115
+ }
116
+ try {
117
+ return await fetchPage();
118
+ } catch (error) {
119
+ lastError = error;
120
+ if (attempt === maxRetries) break;
121
+ const backoff = initialBackoffMs * 2 ** attempt;
122
+ logger?.debug('loadHistoryPages.fetchPageWithRetry(); page fetch failed, retrying', {
123
+ attempt: attempt + 1,
124
+ maxRetries,
125
+ backoff,
126
+ });
127
+ await sleep(backoff, signal);
128
+ }
129
+ }
130
+ throw new Ably.ErrorInfo(
131
+ `unable to fetch history page; ${errorMessage(lastError)}`,
132
+ ErrorCode.HistoryFetchFailed,
133
+ 500,
134
+ errorCause(lastError),
135
+ );
136
+ };
137
+
138
+ /**
139
+ * Page through channel history, returning a cursor over Ably pages.
140
+ *
141
+ * Newest-first within each yielded page (matching Ably's native ordering).
142
+ * Caller drives the cursor — calling `next()` until it returns `undefined`
143
+ * or stopping early when a domain-specific stop condition is met
144
+ * (e.g. complete-message counter satisfied, target codec-message-id found,
145
+ * parent chain walk reaches root).
146
+ *
147
+ * The initial Ably history call is awaited eagerly so the returned cursor
148
+ * already knows whether there are pages available (via `hasNext()`).
149
+ * @param channel - The Ably channel to read history from.
150
+ * @param options - Pagination options.
151
+ * @returns A cursor with `hasNext()` (cheap, cursor-only) and `next()` (fetches one page with retry).
152
+ * @throws {Ably.ErrorInfo} `HistoryFetchFailed` on exhausted retry of the initial fetch, or `InvalidArgument` on signal abort.
153
+ */
154
+ export const loadHistoryPages = async (
155
+ channel: Ably.RealtimeChannel,
156
+ options: LoadHistoryPagesOptions,
157
+ ): Promise<HistoryPagesCursor> => {
158
+ const { pageLimit, untilAttach = true, signal, maxRetries = 3, retryBackoffMs = 100, logger } = options;
159
+
160
+ if (signal?.aborted) {
161
+ throw new Ably.ErrorInfo('unable to load history; signal aborted', ErrorCode.InvalidArgument, 400);
162
+ }
163
+
164
+ await channel.attach();
165
+
166
+ const historyParams: Ably.RealtimeHistoryParams = { limit: pageLimit, untilAttach };
167
+
168
+ let currentPage: Ably.PaginatedResult<Ably.InboundMessage> | undefined = await fetchPageWithRetry(
169
+ // eslint-disable-next-line @typescript-eslint/promise-function-async -- channel.history returns a real Promise
170
+ () => channel.history(historyParams),
171
+ maxRetries,
172
+ retryBackoffMs,
173
+ signal,
174
+ logger,
175
+ );
176
+ let firstYielded = false;
177
+
178
+ // Compute whether the cursor has another page available. Cheap — no
179
+ // network. Reflects the latest fetched page's `hasNext()` plus the signal
180
+ // check.
181
+ const hasNext = (): boolean => {
182
+ if (currentPage === undefined) return false;
183
+ if (signal?.aborted) return false;
184
+ if (!firstYielded) return true;
185
+ return currentPage.hasNext();
186
+ };
187
+
188
+ const next = async (): Promise<readonly Ably.InboundMessage[] | undefined> => {
189
+ if (currentPage === undefined) return undefined;
190
+ if (signal?.aborted) {
191
+ throw new Ably.ErrorInfo('unable to load history; signal aborted', ErrorCode.InvalidArgument, 400);
192
+ }
193
+
194
+ if (!firstYielded) {
195
+ firstYielded = true;
196
+ return currentPage.items;
197
+ }
198
+
199
+ if (!currentPage.hasNext()) {
200
+ currentPage = undefined;
201
+ return undefined;
202
+ }
203
+
204
+ const nextPage: Ably.PaginatedResult<Ably.InboundMessage> | undefined = await fetchPageWithRetry(
205
+ async () => (await currentPage?.next()) ?? undefined,
206
+ maxRetries,
207
+ retryBackoffMs,
208
+ signal,
209
+ logger,
210
+ );
211
+ if (!nextPage) {
212
+ currentPage = undefined;
213
+ return undefined;
214
+ }
215
+ currentPage = nextPage;
216
+ return nextPage.items;
217
+ };
218
+
219
+ return { hasNext, next };
220
+ };
@@ -0,0 +1,271 @@
1
+ /**
2
+ * loadHistory — load conversation history from an Ably channel and return
3
+ * the raw Ably messages as a paginated HistoryPage result.
4
+ *
5
+ * This does NOT decode: it pages back through Ably history via the shared
6
+ * {@link loadHistoryPages} primitive until `limit` complete messages are
7
+ * present, then hands the raw Ably messages (oldest-first) to the caller.
8
+ * The View re-decodes them into the Tree itself, so load-history only needs
9
+ * a cheap, header-based completion counter to decide when to stop paging.
10
+ *
11
+ * The `limit` option controls the number of complete **messages** per page,
12
+ * not the number of Ably 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 page boundary are
15
+ * handled by the counter requiring both a start and a terminal signal
16
+ * 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
+ * Spec: AIT-CT11, AIT-CT11b.
23
+ */
24
+
25
+ import type * as Ably from 'ably';
26
+
27
+ import { HEADER_CODEC_MESSAGE_ID, HEADER_DISCRETE, HEADER_STATUS, HEADER_STREAM } from '../../constants.js';
28
+ import type { Logger } from '../../logger.js';
29
+ import { getTransportHeaders } from '../../utils.js';
30
+ import { type HistoryPagesCursor, loadHistoryPages } from './load-history-pages.js';
31
+ import type { HistoryPage, LoadHistoryOptions } from './types.js';
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Shared state across pages within one history traversal
35
+ // ---------------------------------------------------------------------------
36
+
37
+ interface HistoryState {
38
+ /** Cursor over the shared `loadHistoryPages` primitive; each `next()` returns one Ably page's messages (newest-first within the page). */
39
+ cursor: HistoryPagesCursor;
40
+ /** All raw Ably messages collected so far, in newest-first order (as received from Ably). */
41
+ rawMessages: Ably.InboundMessage[];
42
+ /**
43
+ * How many complete messages have been served to the consumer so far.
44
+ * Drives the buffered-page logic: when a single fetch gathers more than
45
+ * `limit` completions, later pages are served from the buffer without
46
+ * fetching, advancing this counter `limit` at a time.
47
+ */
48
+ returnedCount: number;
49
+ /** How many raw Ably messages have been served to the consumer so far. */
50
+ returnedRawCount: number;
51
+ /**
52
+ * `codec-message-id`s for which a start signal has been seen: any
53
+ * `message.create` / `message.update` / `message.append` with
54
+ * `stream: "true"` (the decoder establishes a tracker via create or
55
+ * first-contact), or a `message.create` carrying `discrete` (a discrete
56
+ * message, created and terminated in one Ably message).
57
+ */
58
+ startedCodecMessageIds: Set<string>;
59
+ /**
60
+ * `codec-message-id`s with a terminal wire signal: either `discrete`
61
+ * on a `message.create` (discrete message) or `status: "complete"`
62
+ * / `"cancelled"` on any action (closed stream).
63
+ */
64
+ terminatedCodecMessageIds: Set<string>;
65
+ /**
66
+ * `codec-message-id`s that are both started AND terminated — counted as
67
+ * complete. The fetch loop reads this set's size to decide when to stop
68
+ * paging. Maintained incrementally by {@link countNewCompletions}. Grows
69
+ * monotonically.
70
+ */
71
+ completedCodecMessageIds: Set<string>;
72
+ logger: Logger;
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Incremental completion counting (header scan, no decode)
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /**
80
+ * Scan newly-added raw messages and track which `codec-message-id`s have
81
+ * become complete. Used by {@link fetchUntilLimit} to decide when enough
82
+ * completed messages have been collected, without running the decoder.
83
+ *
84
+ * A codec-message-id is considered complete only when BOTH of these have been seen:
85
+ * - a "start" signal: either `discrete` on a `message.create`
86
+ * (discrete messages are created and terminated by the same Ably message
87
+ * message), OR any `message.create` / `message.update` / `message.append`
88
+ * with `stream: "true"` (the decoder establishes a tracker via
89
+ * create or first-contact).
90
+ * - a "terminal" signal: `discrete` on the create, or
91
+ * `status: "complete"` / `"cancelled"` on any later action.
92
+ *
93
+ * Why update and append count as starts: Ably history can compact a live
94
+ * `create + append + ... + append{status:complete}` sequence into a single
95
+ * `message.update` with `STREAM=true` and `STATUS=complete`. The decoder
96
+ * handles that via first-contact. Counting only `message.create` as a start
97
+ * would cause the fetch loop to page past a compacted run without ever
98
+ * marking it complete.
99
+ *
100
+ * Requiring both halves matters when a streaming run spans a page
101
+ * boundary: the terminal arrives in the newer page (fetched first) while
102
+ * the start sits in an older page. Counting the terminal alone would stop
103
+ * the fetch loop prematurely - the decoder would have no stream state to
104
+ * resolve, and the message wouldn't make it into the result.
105
+ *
106
+ * Messages skipped for counting:
107
+ * - Missing `codec-message-id`: lifecycle events not tied to a domain message.
108
+ * - `message.delete`: clears the tracker, doesn't produce output.
109
+ *
110
+ * Amend-class Ably messages (events targeting an existing message via
111
+ * `HEADER_CODEC_MESSAGE_ID`) flow through the same counter — the Sets naturally
112
+ * dedup so a tool-output amend on an already-seen codec-message-id is idempotent.
113
+ *
114
+ * Known edge case: if Ably history is truncated and a terminal survives
115
+ * while every start signal for its codec-message-id has rolled off, the counter will
116
+ * never mark that `codec-message-id` complete. The loop keeps fetching until it runs
117
+ * out of pages, then returns whatever raw messages it collected.
118
+ * @param state - The shared history traversal state.
119
+ * @param newMessages - The Ably messages just pushed onto `state.rawMessages`.
120
+ */
121
+ const countNewCompletions = (state: HistoryState, newMessages: readonly Ably.InboundMessage[]): void => {
122
+ for (const msg of newMessages) {
123
+ const headers = getTransportHeaders(msg);
124
+ const codecMessageId = headers[HEADER_CODEC_MESSAGE_ID];
125
+ if (!codecMessageId) continue;
126
+
127
+ const action = msg.action;
128
+ const isDiscreteCreate = action === 'message.create' && HEADER_DISCRETE in headers;
129
+ // Any content-producing action on a streamed serial counts as a start:
130
+ // the decoder uses create or first-contact (update/append) to establish
131
+ // its tracker. Delete clears tracker state and emits nothing, so it
132
+ // never counts as a start.
133
+ const hasStreamContent =
134
+ headers[HEADER_STREAM] === 'true' &&
135
+ (action === 'message.create' || action === 'message.update' || action === 'message.append');
136
+ const status = headers[HEADER_STATUS];
137
+ const isTerminal = status === 'complete' || status === 'cancelled';
138
+
139
+ if (isDiscreteCreate || hasStreamContent) state.startedCodecMessageIds.add(codecMessageId);
140
+ if (isDiscreteCreate || isTerminal) state.terminatedCodecMessageIds.add(codecMessageId);
141
+ if (state.startedCodecMessageIds.has(codecMessageId) && state.terminatedCodecMessageIds.has(codecMessageId)) {
142
+ state.completedCodecMessageIds.add(codecMessageId);
143
+ }
144
+ }
145
+ };
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Pull pages from the shared iterator until we have enough completions
149
+ // ---------------------------------------------------------------------------
150
+
151
+ /**
152
+ * Pull chunks from the shared {@link loadHistoryPages} iterator until enough
153
+ * completed messages have been collected (target = already-returned +
154
+ * `limit`) or the iterator is exhausted.
155
+ *
156
+ * Uses {@link countNewCompletions} — a cheap O(new messages) header scan —
157
+ * to decide when to stop, rather than running the decoder per page.
158
+ * @param state - The shared history traversal state.
159
+ * @param limit - Target number of completed messages beyond what has already been returned.
160
+ */
161
+ const fetchUntilLimit = async (state: HistoryState, limit: number): Promise<void> => {
162
+ const target = state.returnedCount + limit;
163
+ while (state.completedCodecMessageIds.size < target && state.cursor.hasNext()) {
164
+ state.logger.debug('loadHistory.fetchUntilLimit(); pulling next page', {
165
+ collected: state.rawMessages.length,
166
+ completed: state.completedCodecMessageIds.size,
167
+ });
168
+ const chunk = await state.cursor.next();
169
+ if (!chunk) break;
170
+ state.rawMessages.push(...chunk);
171
+ countNewCompletions(state, chunk);
172
+ }
173
+ };
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Build HistoryPage result from current state
177
+ // ---------------------------------------------------------------------------
178
+
179
+ /**
180
+ * Build a HistoryPage of raw Ably messages from the current fetch state.
181
+ * @param state - The shared history traversal state.
182
+ * @param limit - Max complete messages per page.
183
+ * @returns A page of raw history messages with a `next()` cursor.
184
+ */
185
+ const buildResult = (state: HistoryState, limit: number): HistoryPage => {
186
+ // Advance the served-completion counter by up to `limit`, mirroring the
187
+ // page granularity the consumer asked for. `rawMessages` for this page are
188
+ // all messages fetched since the previous page (empty for buffered pages).
189
+ const totalCompleted = state.completedCodecMessageIds.size;
190
+ const served = Math.min(limit, Math.max(0, totalCompleted - state.returnedCount));
191
+ state.returnedCount += served;
192
+
193
+ const moreCompleted = totalCompleted > state.returnedCount;
194
+ const moreFromCursor = state.cursor.hasNext();
195
+
196
+ // Raw Ably messages for this page in chronological order (oldest first).
197
+ const newRawCount = state.rawMessages.length - state.returnedRawCount;
198
+ const rawMessages = newRawCount > 0 ? state.rawMessages.slice(state.returnedRawCount).toReversed() : [];
199
+ state.returnedRawCount = state.rawMessages.length;
200
+
201
+ return {
202
+ rawMessages,
203
+ hasNext: () => moreCompleted || moreFromCursor,
204
+ next: async () => {
205
+ if (moreCompleted) {
206
+ return buildResult(state, limit);
207
+ }
208
+ if (!moreFromCursor) return;
209
+ await fetchUntilLimit(state, limit);
210
+ return buildResult(state, limit);
211
+ },
212
+ };
213
+ };
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Public API
217
+ // ---------------------------------------------------------------------------
218
+
219
+ /**
220
+ * Load conversation history from a channel and return the raw Ably messages.
221
+ *
222
+ * Drives the shared {@link loadHistoryPages} primitive with
223
+ * `untilAttach: true` (gapless continuity with the live subscription) and a
224
+ * Ably message page limit that overprovisions for the many-Ably-messages-per-domain-message
225
+ * ratio. Each `HistoryPage` returned covers up to `limit` complete domain
226
+ * messages; subsequent pages drain over-collected messages from the buffer
227
+ * before pulling more from the iterator.
228
+ *
229
+ * The `limit` option controls the number of complete messages returned per
230
+ * page, not the number of Ably messages fetched.
231
+ * @param channel - The Ably channel to load history from.
232
+ * @param options - Pagination options.
233
+ * @param logger - Logger for diagnostic output.
234
+ * @returns The first page of raw history messages.
235
+ */
236
+ // Spec: AIT-CT11, AIT-CT11b
237
+ export const loadHistory = async (
238
+ channel: Ably.RealtimeChannel,
239
+ options: LoadHistoryOptions | undefined,
240
+ logger: Logger,
241
+ ): Promise<HistoryPage> => {
242
+ const limit = options?.limit ?? 100;
243
+
244
+ logger.trace('loadHistory();', { limit });
245
+
246
+ // Request more Ably messages per page than the domain-message limit to
247
+ // account for the many-to-one ratio (multiple Ably messages per domain message).
248
+ // The wire pagination is internal to the iterator; the consumer-visible
249
+ // pagination is by complete domain messages.
250
+ const wireLimit = limit * 10;
251
+
252
+ const cursor = await loadHistoryPages(channel, {
253
+ pageLimit: wireLimit,
254
+ untilAttach: true,
255
+ logger,
256
+ });
257
+
258
+ const state: HistoryState = {
259
+ cursor,
260
+ rawMessages: [],
261
+ returnedCount: 0,
262
+ returnedRawCount: 0,
263
+ startedCodecMessageIds: new Set<string>(),
264
+ terminatedCodecMessageIds: new Set<string>(),
265
+ completedCodecMessageIds: new Set<string>(),
266
+ logger,
267
+ };
268
+
269
+ await fetchUntilLimit(state, limit);
270
+ return buildResult(state, limit);
271
+ };
@@ -1,53 +1,68 @@
1
1
  /**
2
2
  * Pure stream piping function.
3
3
  *
4
- * Reads events from a ReadableStream, writes them to a streaming encoder,
5
- * and handles abort/error. No dependencies on turn state or transport internals.
4
+ * Reads outputs from a ReadableStream, writes them to an encoder via
5
+ * `publishOutput`, and handles cancel/error. No dependencies on run
6
+ * state or session internals.
6
7
  */
7
8
 
8
9
  import type { Logger } from '../../logger.js';
9
- import type { StreamEncoder, WriteOptions } from '../codec/types.js';
10
+ import type { CodecInputEvent, CodecOutputEvent, Encoder, WriteOptions } from '../codec/types.js';
10
11
  import type { StreamResult } from './types.js';
11
12
 
12
13
  /**
13
- * Pipe an event stream through an encoder to the channel.
14
+ * Adapt an AbortSignal into a promise that resolves once the signal aborts,
15
+ * paired with a cleanup that detaches the listener. With no signal the promise
16
+ * never resolves (there is no cancellation path); an already-aborted signal
17
+ * resolves immediately. `cleanup` is a no-op unless a listener was attached.
18
+ * @param signal - The AbortSignal to watch, or undefined for no cancellation.
19
+ * @returns The abort promise and a cleanup to call when racing is done.
20
+ */
21
+ const abortSignalToPromise = (signal: AbortSignal | undefined): { promise: Promise<void>; cleanup: () => void } => {
22
+ let listener: (() => void) | undefined;
23
+ const promise =
24
+ signal === undefined
25
+ ? // eslint-disable-next-line @typescript-eslint/no-empty-function -- never-resolving promise: no signal means no cancellation path
26
+ new Promise<void>(() => {})
27
+ : signal.aborted
28
+ ? Promise.resolve()
29
+ : new Promise<void>((resolve) => {
30
+ listener = () => {
31
+ resolve();
32
+ };
33
+ signal.addEventListener('abort', listener, { once: true });
34
+ });
35
+ const cleanup = (): void => {
36
+ if (listener && signal) signal.removeEventListener('abort', listener);
37
+ };
38
+ return { promise, cleanup };
39
+ };
40
+
41
+ /**
42
+ * Pipe an output stream through an encoder to the channel.
14
43
  *
15
44
  * Returns when the stream completes, is cancelled (via signal), or errors.
16
45
  * The `reason` field of the result indicates which case occurred.
17
- * @param stream - The event stream to read from.
18
- * @param encoder - The streaming encoder to write events through.
19
- * @param signal - Abort signal to monitor for cancellation.
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`.
46
+ * @param stream - The output stream to read from.
47
+ * @param encoder - The encoder to publish outputs through.
48
+ * @param signal - AbortSignal to monitor for cancellation.
49
+ * @param onCancelled - Optional callback invoked when the stream is cancelled, before the stream ends.
50
+ * @param resolveWriteOptions - Optional per-output hook returning {@link WriteOptions} overrides to pass to `encoder.publishOutput`.
22
51
  * @param logger - Optional logger for diagnostic output.
23
- * @returns The reason the pipe ended.
52
+ * @returns A {@link StreamResult}: `reason` is why the pipe ended, and `error` holds the caught error when `reason` is `'error'`.
24
53
  */
25
- export const pipeStream = async <TEvent, TMessage>(
26
- stream: ReadableStream<TEvent>,
27
- encoder: StreamEncoder<TEvent, TMessage>,
54
+ export const pipeStream = async <TInput extends CodecInputEvent, TOutput extends CodecOutputEvent>(
55
+ stream: ReadableStream<TOutput>,
56
+ encoder: Encoder<TInput, TOutput>,
28
57
  signal: AbortSignal | undefined,
29
- onAbort?: (write: (event: TEvent) => Promise<void>) => void | Promise<void>,
30
- resolveWriteOptions?: (event: TEvent) => WriteOptions | undefined,
58
+ onCancelled?: (write: (output: TOutput) => Promise<void>) => void | Promise<void>,
59
+ resolveWriteOptions?: (output: TOutput) => WriteOptions | undefined,
31
60
  logger?: Logger,
32
61
  ): Promise<StreamResult> => {
33
62
  logger?.trace('pipeStream();');
34
63
 
35
64
  const reader = stream.getReader();
36
-
37
- let abortListener: (() => void) | undefined;
38
- const abortPromise = signal
39
- ? new Promise<void>((resolve) => {
40
- if (signal.aborted) {
41
- resolve();
42
- return;
43
- }
44
- abortListener = () => {
45
- resolve();
46
- };
47
- signal.addEventListener('abort', abortListener, { once: true });
48
- })
49
- : // eslint-disable-next-line @typescript-eslint/no-empty-function -- never-resolving promise: no signal means no cancellation path
50
- new Promise<void>(() => {});
65
+ const abort = abortSignalToPromise(signal);
51
66
 
52
67
  let reason: StreamResult['reason'] = 'complete';
53
68
  let caughtError: Error | undefined;
@@ -55,28 +70,37 @@ export const pipeStream = async <TEvent, TMessage>(
55
70
  try {
56
71
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentional infinite loop broken by return/break
57
72
  while (true) {
58
- // .then() is intentional: transforms the abort signal into a discriminant
73
+ // .then() is intentional: transforms the AbortSignal into a discriminant
59
74
  // for Promise.race — no async/await equivalent for this pattern.
60
- const result = await Promise.race([reader.read(), abortPromise.then(() => 'aborted' as const)]);
75
+ const result = await Promise.race([reader.read(), abort.promise.then(() => 'cancelled' as const)]);
61
76
 
62
- if (result === 'aborted') {
77
+ if (result === 'cancelled') {
63
78
  reason = 'cancelled';
64
- logger?.debug('pipeStream(); stream cancelled by abort signal');
65
- if (onAbort) {
66
- await onAbort(async (event: TEvent) => encoder.appendEvent(event));
79
+ logger?.debug('pipeStream(); stream cancelled by AbortSignal');
80
+ if (onCancelled) {
81
+ await onCancelled(async (output: TOutput) => encoder.publishOutput(output));
67
82
  }
68
- await encoder.abort('cancelled');
83
+ // Transport mechanics only — close in-flight streamed messages as
84
+ // cancelled. Run termination is the transport ai-run-end event,
85
+ // guaranteed by Run.pipe on a cancelled result.
86
+ await encoder.cancelStreams();
69
87
  break;
70
88
  }
71
89
 
72
90
  const { done, value } = result;
73
91
  if (done) {
92
+ // An agent-side self-abort (e.g. the AI SDK's abort signal firing)
93
+ // completes the stream without end chunks for in-flight streamed
94
+ // messages. Terminate any still-open wire streams with a cancelled
95
+ // status so decoders and history see a terminal; streams that closed
96
+ // normally are skipped (no-op on a clean completion).
97
+ await encoder.cancelStreams();
74
98
  await encoder.close();
75
99
  logger?.debug('pipeStream(); stream completed');
76
100
  break;
77
101
  }
78
102
 
79
- await encoder.appendEvent(value, resolveWriteOptions?.(value));
103
+ await encoder.publishOutput(value, resolveWriteOptions?.(value));
80
104
  }
81
105
  } catch (error) {
82
106
  reason = 'error';
@@ -90,7 +114,7 @@ export const pipeStream = async <TEvent, TMessage>(
90
114
  // the StreamResult reason ("error").
91
115
  }
92
116
  } finally {
93
- if (abortListener) signal?.removeEventListener('abort', abortListener);
117
+ abort.cleanup();
94
118
  reader.releaseLock();
95
119
  }
96
120