@ably/ai-transport 0.2.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 (166) hide show
  1. package/README.md +10 -19
  2. package/dist/ably-ai-transport.js +1790 -1091
  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 +2 -2
  7. package/dist/core/agent.d.ts +20 -5
  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 +4 -1
  11. package/dist/core/codec/define-codec.d.ts +100 -0
  12. package/dist/core/codec/encoder.d.ts +2 -7
  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 -1
  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/output-descriptor-decoder.d.ts +29 -0
  20. package/dist/core/codec/output-descriptor-encoder.d.ts +31 -0
  21. package/dist/core/codec/output-descriptors.d.ts +237 -0
  22. package/dist/core/codec/types.d.ts +95 -36
  23. package/dist/core/codec/well-known-inputs.d.ts +52 -0
  24. package/dist/core/transport/agent-view.d.ts +296 -0
  25. package/dist/core/transport/decode-fold.d.ts +40 -32
  26. package/dist/core/transport/headers.d.ts +30 -1
  27. package/dist/core/transport/index.d.ts +1 -1
  28. package/dist/core/transport/invocation.d.ts +1 -1
  29. package/dist/core/transport/load-history-pages.d.ts +71 -0
  30. package/dist/core/transport/load-history.d.ts +21 -16
  31. package/dist/core/transport/run-manager.d.ts +9 -11
  32. package/dist/core/transport/session-support.d.ts +55 -0
  33. package/dist/core/transport/tree.d.ts +165 -15
  34. package/dist/core/transport/types/agent.d.ts +120 -98
  35. package/dist/core/transport/types/client.d.ts +45 -12
  36. package/dist/core/transport/types/tree.d.ts +52 -10
  37. package/dist/core/transport/types/view.d.ts +55 -28
  38. package/dist/core/transport/view.d.ts +176 -58
  39. package/dist/core/transport/wire-log.d.ts +102 -0
  40. package/dist/errors.d.ts +10 -4
  41. package/dist/index.d.ts +6 -5
  42. package/dist/react/ably-ai-transport-react.js +784 -415
  43. package/dist/react/ably-ai-transport-react.js.map +1 -1
  44. package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
  45. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
  46. package/dist/react/contexts/client-session-context.d.ts +2 -1
  47. package/dist/react/contexts/client-session-provider.d.ts +3 -0
  48. package/dist/react/index.d.ts +2 -1
  49. package/dist/react/internal/skipped-session.d.ts +8 -0
  50. package/dist/react/use-view.d.ts +3 -3
  51. package/dist/utils.d.ts +22 -54
  52. package/dist/vercel/ably-ai-transport-vercel.js +2297 -2026
  53. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
  54. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
  55. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
  56. package/dist/vercel/codec/decode-lifecycle.d.ts +9 -0
  57. package/dist/vercel/codec/events.d.ts +1 -2
  58. package/dist/vercel/codec/fields.d.ts +44 -0
  59. package/dist/vercel/codec/fold-content.d.ts +16 -0
  60. package/dist/vercel/codec/fold-data.d.ts +16 -0
  61. package/dist/vercel/codec/fold-input.d.ts +67 -0
  62. package/dist/vercel/codec/fold-lifecycle.d.ts +16 -0
  63. package/dist/vercel/codec/fold-text.d.ts +16 -0
  64. package/dist/vercel/codec/fold-tool-input.d.ts +17 -0
  65. package/dist/vercel/codec/fold-tool-output.d.ts +16 -0
  66. package/dist/vercel/codec/index.d.ts +5 -30
  67. package/dist/vercel/codec/inputs.d.ts +11 -0
  68. package/dist/vercel/codec/outputs.d.ts +11 -0
  69. package/dist/vercel/codec/reducer-state.d.ts +121 -0
  70. package/dist/vercel/codec/reducer.d.ts +20 -102
  71. package/dist/vercel/codec/tool-transitions.d.ts +0 -6
  72. package/dist/vercel/codec/wire-data.d.ts +34 -0
  73. package/dist/vercel/index.d.ts +1 -0
  74. package/dist/vercel/react/ably-ai-transport-vercel-react.js +2013 -9500
  75. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
  76. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +1 -70
  77. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
  78. package/dist/vercel/react/contexts/chat-transport-context.d.ts +2 -1
  79. package/dist/vercel/run-end-reason.d.ts +66 -11
  80. package/dist/vercel/tool-part.d.ts +21 -0
  81. package/dist/vercel/transport/chat-transport.d.ts +0 -2
  82. package/dist/vercel/transport/index.d.ts +1 -1
  83. package/dist/vercel/transport/run-output-stream.d.ts +6 -8
  84. package/dist/version.d.ts +1 -1
  85. package/package.json +2 -2
  86. package/src/constants.ts +2 -2
  87. package/src/core/agent.ts +43 -19
  88. package/src/core/channel-options.ts +89 -0
  89. package/src/core/codec/codec-event.ts +27 -0
  90. package/src/core/codec/decoder.ts +145 -21
  91. package/src/core/codec/define-codec.ts +432 -0
  92. package/src/core/codec/encoder.ts +13 -54
  93. package/src/core/codec/field-bag.ts +142 -0
  94. package/src/core/codec/fields.ts +193 -0
  95. package/src/core/codec/index.ts +43 -0
  96. package/src/core/codec/input-descriptor-decoder.ts +97 -0
  97. package/src/core/codec/input-descriptor-encoder.ts +150 -0
  98. package/src/core/codec/input-descriptors.ts +373 -0
  99. package/src/core/codec/output-descriptor-decoder.ts +139 -0
  100. package/src/core/codec/output-descriptor-encoder.ts +101 -0
  101. package/src/core/codec/output-descriptors.ts +307 -0
  102. package/src/core/codec/types.ts +99 -36
  103. package/src/core/codec/well-known-inputs.ts +96 -0
  104. package/src/core/transport/agent-session.ts +330 -589
  105. package/src/core/transport/agent-view.ts +738 -0
  106. package/src/core/transport/client-session.ts +74 -69
  107. package/src/core/transport/decode-fold.ts +57 -47
  108. package/src/core/transport/headers.ts +57 -4
  109. package/src/core/transport/index.ts +2 -1
  110. package/src/core/transport/invocation.ts +1 -1
  111. package/src/core/transport/load-history-pages.ts +220 -0
  112. package/src/core/transport/load-history.ts +63 -61
  113. package/src/core/transport/pipe-stream.ts +10 -1
  114. package/src/core/transport/run-manager.ts +25 -31
  115. package/src/core/transport/session-support.ts +96 -0
  116. package/src/core/transport/tree.ts +414 -47
  117. package/src/core/transport/types/agent.ts +129 -102
  118. package/src/core/transport/types/client.ts +49 -13
  119. package/src/core/transport/types/tree.ts +61 -12
  120. package/src/core/transport/types/view.ts +57 -28
  121. package/src/core/transport/view.ts +520 -172
  122. package/src/core/transport/wire-log.ts +189 -0
  123. package/src/errors.ts +10 -3
  124. package/src/index.ts +44 -11
  125. package/src/react/contexts/client-session-context.ts +1 -1
  126. package/src/react/contexts/client-session-provider.tsx +38 -2
  127. package/src/react/index.ts +2 -1
  128. package/src/react/internal/skipped-session.ts +62 -0
  129. package/src/react/use-client-session.ts +7 -30
  130. package/src/react/use-view.ts +3 -3
  131. package/src/utils.ts +31 -97
  132. package/src/vercel/codec/decode-lifecycle.ts +70 -0
  133. package/src/vercel/codec/events.ts +1 -3
  134. package/src/vercel/codec/fields.ts +58 -0
  135. package/src/vercel/codec/fold-content.ts +54 -0
  136. package/src/vercel/codec/fold-data.ts +46 -0
  137. package/src/vercel/codec/fold-input.ts +255 -0
  138. package/src/vercel/codec/fold-lifecycle.ts +85 -0
  139. package/src/vercel/codec/fold-text.ts +55 -0
  140. package/src/vercel/codec/fold-tool-input.ts +86 -0
  141. package/src/vercel/codec/fold-tool-output.ts +79 -0
  142. package/src/vercel/codec/index.ts +23 -63
  143. package/src/vercel/codec/inputs.ts +116 -0
  144. package/src/vercel/codec/outputs.ts +207 -0
  145. package/src/vercel/codec/reducer-state.ts +169 -0
  146. package/src/vercel/codec/reducer.ts +52 -838
  147. package/src/vercel/codec/tool-transitions.ts +1 -12
  148. package/src/vercel/codec/wire-data.ts +64 -0
  149. package/src/vercel/index.ts +1 -0
  150. package/src/vercel/react/contexts/chat-transport-context.ts +1 -1
  151. package/src/vercel/react/use-chat-transport.ts +8 -28
  152. package/src/vercel/react/use-message-sync.ts +5 -10
  153. package/src/vercel/run-end-reason.ts +95 -16
  154. package/src/vercel/tool-part.ts +25 -0
  155. package/src/vercel/transport/chat-transport.ts +10 -22
  156. package/src/vercel/transport/index.ts +1 -1
  157. package/src/vercel/transport/run-output-stream.ts +7 -8
  158. package/src/version.ts +1 -1
  159. package/dist/core/transport/branch-chain.d.ts +0 -43
  160. package/dist/core/transport/load-conversation.d.ts +0 -128
  161. package/dist/vercel/codec/decoder.d.ts +0 -9
  162. package/dist/vercel/codec/encoder.d.ts +0 -11
  163. package/src/core/transport/branch-chain.ts +0 -58
  164. package/src/core/transport/load-conversation.ts +0 -355
  165. package/src/vercel/codec/decoder.ts +0 -696
  166. package/src/vercel/codec/encoder.ts +0 -548
@@ -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
+ };
@@ -1,23 +1,25 @@
1
1
  /**
2
2
  * loadHistory — load conversation history from an Ably channel and return
3
- * the raw wire messages as a paginated HistoryPage result.
3
+ * the raw Ably messages as a paginated HistoryPage result.
4
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.
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
10
  *
11
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
12
+ * not the number of Ably messages fetched. A message is complete when
13
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.
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
17
  *
18
18
  * Because Ably history returns newest-first, each page's `rawMessages` are
19
19
  * reversed to chronological (oldest-first) so the caller can fold them in
20
20
  * order.
21
+ *
22
+ * Spec: AIT-CT11, AIT-CT11b.
21
23
  */
22
24
 
23
25
  import type * as Ably from 'ably';
@@ -25,6 +27,7 @@ import type * as Ably from 'ably';
25
27
  import { HEADER_CODEC_MESSAGE_ID, HEADER_DISCRETE, HEADER_STATUS, HEADER_STREAM } from '../../constants.js';
26
28
  import type { Logger } from '../../logger.js';
27
29
  import { getTransportHeaders } from '../../utils.js';
30
+ import { type HistoryPagesCursor, loadHistoryPages } from './load-history-pages.js';
28
31
  import type { HistoryPage, LoadHistoryOptions } from './types.js';
29
32
 
30
33
  // ---------------------------------------------------------------------------
@@ -32,6 +35,8 @@ import type { HistoryPage, LoadHistoryOptions } from './types.js';
32
35
  // ---------------------------------------------------------------------------
33
36
 
34
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;
35
40
  /** All raw Ably messages collected so far, in newest-first order (as received from Ably). */
36
41
  rawMessages: Ably.InboundMessage[];
37
42
  /**
@@ -43,14 +48,12 @@ interface HistoryState {
43
48
  returnedCount: number;
44
49
  /** How many raw Ably messages have been served to the consumer so far. */
45
50
  returnedRawCount: number;
46
- /** The last Ably page cursor for continued pagination. */
47
- lastAblyPage: Ably.PaginatedResult<Ably.InboundMessage> | undefined;
48
51
  /**
49
52
  * `codec-message-id`s for which a start signal has been seen: any
50
53
  * `message.create` / `message.update` / `message.append` with
51
54
  * `stream: "true"` (the decoder establishes a tracker via create or
52
55
  * first-contact), or a `message.create` carrying `discrete` (a discrete
53
- * message, created and terminated in one wire message).
56
+ * message, created and terminated in one Ably message).
54
57
  */
55
58
  startedCodecMessageIds: Set<string>;
56
59
  /**
@@ -80,7 +83,7 @@ interface HistoryState {
80
83
  *
81
84
  * A codec-message-id is considered complete only when BOTH of these have been seen:
82
85
  * - a "start" signal: either `discrete` on a `message.create`
83
- * (discrete messages are created and terminated by the same wire
86
+ * (discrete messages are created and terminated by the same Ably message
84
87
  * message), OR any `message.create` / `message.update` / `message.append`
85
88
  * with `stream: "true"` (the decoder establishes a tracker via
86
89
  * create or first-contact).
@@ -104,7 +107,7 @@ interface HistoryState {
104
107
  * - Missing `codec-message-id`: lifecycle events not tied to a domain message.
105
108
  * - `message.delete`: clears the tracker, doesn't produce output.
106
109
  *
107
- * Amend-class wire messages (events targeting an existing message via
110
+ * Amend-class Ably messages (events targeting an existing message via
108
111
  * `HEADER_CODEC_MESSAGE_ID`) flow through the same counter — the Sets naturally
109
112
  * dedup so a tool-output amend on an already-seen codec-message-id is idempotent.
110
113
  *
@@ -142,39 +145,30 @@ const countNewCompletions = (state: HistoryState, newMessages: readonly Ably.Inb
142
145
  };
143
146
 
144
147
  // ---------------------------------------------------------------------------
145
- // Fetch Ably pages until we have enough completed messages
148
+ // Pull pages from the shared iterator until we have enough completions
146
149
  // ---------------------------------------------------------------------------
147
150
 
148
151
  /**
149
- * Fetch Ably history pages until we have enough completed messages.
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.
150
155
  *
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.
156
+ * Uses {@link countNewCompletions} a cheap O(new messages) header scan —
157
+ * to decide when to stop, rather than running the decoder per page.
153
158
  * @param state - The shared history traversal state.
154
- * @param ablyPage - The current Ably paginated result to start from.
155
159
  * @param limit - Target number of completed messages beyond what has already been returned.
156
160
  */
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
-
161
+ const fetchUntilLimit = async (state: HistoryState, limit: number): Promise<void> => {
166
162
  const target = state.returnedCount + limit;
167
- while (state.completedCodecMessageIds.size < target && ablyPage.hasNext()) {
168
- state.logger.debug('loadHistory.fetchUntilLimit(); fetching next page', {
163
+ while (state.completedCodecMessageIds.size < target && state.cursor.hasNext()) {
164
+ state.logger.debug('loadHistory.fetchUntilLimit(); pulling next page', {
169
165
  collected: state.rawMessages.length,
170
166
  completed: state.completedCodecMessageIds.size,
171
167
  });
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);
168
+ const chunk = await state.cursor.next();
169
+ if (!chunk) break;
170
+ state.rawMessages.push(...chunk);
171
+ countNewCompletions(state, chunk);
178
172
  }
179
173
  };
180
174
 
@@ -183,7 +177,7 @@ const fetchUntilLimit = async (
183
177
  // ---------------------------------------------------------------------------
184
178
 
185
179
  /**
186
- * Build a HistoryPage of raw wire messages from the current fetch state.
180
+ * Build a HistoryPage of raw Ably messages from the current fetch state.
187
181
  * @param state - The shared history traversal state.
188
182
  * @param limit - Max complete messages per page.
189
183
  * @returns A page of raw history messages with a `next()` cursor.
@@ -191,13 +185,13 @@ const fetchUntilLimit = async (
191
185
  const buildResult = (state: HistoryState, limit: number): HistoryPage => {
192
186
  // Advance the served-completion counter by up to `limit`, mirroring the
193
187
  // page granularity the consumer asked for. `rawMessages` for this page are
194
- // all wires fetched since the previous page (empty for buffered pages).
188
+ // all messages fetched since the previous page (empty for buffered pages).
195
189
  const totalCompleted = state.completedCodecMessageIds.size;
196
190
  const served = Math.min(limit, Math.max(0, totalCompleted - state.returnedCount));
197
191
  state.returnedCount += served;
198
192
 
199
193
  const moreCompleted = totalCompleted > state.returnedCount;
200
- const moreAblyPages = state.lastAblyPage?.hasNext() ?? false;
194
+ const moreFromCursor = state.cursor.hasNext();
201
195
 
202
196
  // Raw Ably messages for this page in chronological order (oldest first).
203
197
  const newRawCount = state.rawMessages.length - state.returnedRawCount;
@@ -206,15 +200,13 @@ const buildResult = (state: HistoryState, limit: number): HistoryPage => {
206
200
 
207
201
  return {
208
202
  rawMessages,
209
- hasNext: () => moreCompleted || moreAblyPages,
203
+ hasNext: () => moreCompleted || moreFromCursor,
210
204
  next: async () => {
211
205
  if (moreCompleted) {
212
206
  return buildResult(state, limit);
213
207
  }
214
- if (!moreAblyPages || !state.lastAblyPage) return;
215
- const nextAbly = await state.lastAblyPage.next();
216
- if (!nextAbly) return;
217
- await fetchUntilLimit(state, nextAbly, limit);
208
+ if (!moreFromCursor) return;
209
+ await fetchUntilLimit(state, limit);
218
210
  return buildResult(state, limit);
219
211
  },
220
212
  };
@@ -225,14 +217,17 @@ const buildResult = (state: HistoryState, limit: number): HistoryPage => {
225
217
  // ---------------------------------------------------------------------------
226
218
 
227
219
  /**
228
- * Load conversation history from a channel and return the raw wire messages.
220
+ * Load conversation history from a channel and return the raw Ably messages.
229
221
  *
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.
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.
233
228
  *
234
- * The `limit` option controls the number of complete messages
235
- * returned per page, not the number of Ably wire messages fetched.
229
+ * The `limit` option controls the number of complete messages returned per
230
+ * page, not the number of Ably messages fetched.
236
231
  * @param channel - The Ably channel to load history from.
237
232
  * @param options - Pagination options.
238
233
  * @param logger - Logger for diagnostic output.
@@ -245,25 +240,32 @@ export const loadHistory = async (
245
240
  logger: Logger,
246
241
  ): Promise<HistoryPage> => {
247
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
+
248
258
  const state: HistoryState = {
259
+ cursor,
249
260
  rawMessages: [],
250
261
  returnedCount: 0,
251
262
  returnedRawCount: 0,
252
- lastAblyPage: undefined,
253
263
  startedCodecMessageIds: new Set<string>(),
254
264
  terminatedCodecMessageIds: new Set<string>(),
255
265
  completedCodecMessageIds: new Set<string>(),
256
266
  logger,
257
267
  };
258
268
 
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);
269
+ await fetchUntilLimit(state, limit);
268
270
  return buildResult(state, limit);
269
271
  };
@@ -80,12 +80,21 @@ export const pipeStream = async <TInput extends CodecInputEvent, TOutput extends
80
80
  if (onCancelled) {
81
81
  await onCancelled(async (output: TOutput) => encoder.publishOutput(output));
82
82
  }
83
- await encoder.cancel('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();
84
87
  break;
85
88
  }
86
89
 
87
90
  const { done, value } = result;
88
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();
89
98
  await encoder.close();
90
99
  logger?.debug('pipeStream(); stream completed');
91
100
  break;
@@ -19,7 +19,7 @@ import type { RunEndReason } from './types.js';
19
19
  * continuation (re-entering an existing run) sets `continuation` and omits the
20
20
  * structural `parent` / `forkOf` / `regenerates` fields.
21
21
  */
22
- export interface StartRunMetadata {
22
+ interface StartRunMetadata {
23
23
  /** Structural parent codec-message-id (fresh run-start only). */
24
24
  parent?: string;
25
25
  /** Forked user-prompt codec-message-id for an edit (fresh run-start only). */
@@ -47,14 +47,9 @@ export interface RunManager {
47
47
  * `ai-run-start` for a fresh run, or `ai-run-resume` when `metadata.continuation`
48
48
  * is set (a subsequent invocation re-entering an existing run). A resume omits
49
49
  * the structural `parent` / `forkOf` / `regenerates` headers — the original
50
- * run-start owns the run's structure. Returns the run's AbortSignal.
50
+ * run-start owns the run's structure.
51
51
  */
52
- startRun(
53
- runId: string,
54
- clientId?: string,
55
- controller?: AbortController,
56
- metadata?: StartRunMetadata,
57
- ): Promise<AbortSignal>;
52
+ startRun(runId: string, clientId?: string, controller?: AbortController, metadata?: StartRunMetadata): Promise<void>;
58
53
  /**
59
54
  * Suspend a run. Publishes run-suspend on the channel and drops the run's
60
55
  * active-run entry — the agent process terminates on suspend, so there is no
@@ -70,7 +65,10 @@ export interface RunManager {
70
65
  * run-reason header) and drops the run's active-run entry. Carries the same
71
66
  * per-invocation attribution as {@link suspendRun} (`invocationId`,
72
67
  * `inputClientId`, `inputCodecMessageId`), since run-end is the terminal event
73
- * of the ending invocation.
68
+ * of the ending invocation. When `reason` is `'error'` and an `error` is
69
+ * supplied, its `code` and `message` are additionally stamped as the
70
+ * `error-code` / `error-message` headers — a codec-agnostic baseline failure
71
+ * detail for consumers; omitting `error` publishes a bare `reason: 'error'`.
74
72
  */
75
73
  endRun(
76
74
  runId: string,
@@ -78,15 +76,10 @@ export interface RunManager {
78
76
  invocationId?: string,
79
77
  inputClientId?: string,
80
78
  inputCodecMessageId?: string,
79
+ error?: Ably.ErrorInfo,
81
80
  ): Promise<void>;
82
- /** Get the AbortSignal for a run. */
83
- getSignal(runId: string): AbortSignal | undefined;
84
81
  /** Get the clientId that owns a run. */
85
82
  getClientId(runId: string): string | undefined;
86
- /** Fire the AbortSignal for a run to cancel any in-flight work. */
87
- cancel(runId: string): void;
88
- /** Get all active run IDs. */
89
- getActiveRunIds(): string[];
90
83
  /** Cancel all active runs and clear state. */
91
84
  close(): void;
92
85
  }
@@ -119,7 +112,7 @@ class DefaultRunManager implements RunManager {
119
112
  clientId?: string,
120
113
  externalController?: AbortController,
121
114
  metadata?: StartRunMetadata,
122
- ): Promise<AbortSignal> {
115
+ ): Promise<void> {
123
116
  this._logger?.trace('DefaultRunManager.startRun();', { runId, clientId });
124
117
 
125
118
  const controller = externalController ?? new AbortController();
@@ -153,7 +146,6 @@ class DefaultRunManager implements RunManager {
153
146
  });
154
147
 
155
148
  this._logger?.debug('DefaultRunManager.startRun(); run started', { runId });
156
- return controller.signal;
157
149
  }
158
150
 
159
151
  async suspendRun(
@@ -173,9 +165,20 @@ class DefaultRunManager implements RunManager {
173
165
  invocationId?: string,
174
166
  inputClientId?: string,
175
167
  inputCodecMessageId?: string,
168
+ error?: Ably.ErrorInfo,
176
169
  ): Promise<void> {
177
170
  this._logger?.trace('DefaultRunManager.endRun();', { runId, reason });
178
- await this._publishTerminal(EVENT_RUN_END, runId, { reason, invocationId, inputClientId, inputCodecMessageId });
171
+ // Stamp error detail only for a terminal error the agent chose to surface
172
+ // (AIT-ST6b4: explicit, never automatic). error-code / error-message are
173
+ // generic transport headers, so any codec or consumer can read them.
174
+ const errorAttribution = reason === 'error' && error ? { errorCode: error.code, errorMessage: error.message } : {};
175
+ await this._publishTerminal(EVENT_RUN_END, runId, {
176
+ reason,
177
+ invocationId,
178
+ inputClientId,
179
+ inputCodecMessageId,
180
+ ...errorAttribution,
181
+ });
179
182
  this._logger?.debug('DefaultRunManager.endRun(); run ended', { runId, reason });
180
183
  }
181
184
 
@@ -192,6 +195,8 @@ class DefaultRunManager implements RunManager {
192
195
  * @param attribution.invocationId - The invocation's id.
193
196
  * @param attribution.inputClientId - ClientId of the triggering input event.
194
197
  * @param attribution.inputCodecMessageId - Codec-message-id of the triggering input event.
198
+ * @param attribution.errorCode - Numeric error code; set for run-end only when a terminal error is surfaced.
199
+ * @param attribution.errorMessage - Error message; paired with errorCode.
195
200
  */
196
201
  private async _publishTerminal(
197
202
  eventName: string,
@@ -201,6 +206,8 @@ class DefaultRunManager implements RunManager {
201
206
  invocationId?: string;
202
207
  inputClientId?: string;
203
208
  inputCodecMessageId?: string;
209
+ errorCode?: number;
210
+ errorMessage?: string;
204
211
  },
205
212
  ): Promise<void> {
206
213
  const resolvedClientId = this._activeRuns.get(runId)?.clientId ?? '';
@@ -209,23 +216,10 @@ class DefaultRunManager implements RunManager {
209
216
  this._activeRuns.delete(runId);
210
217
  }
211
218
 
212
- getSignal(runId: string): AbortSignal | undefined {
213
- return this._activeRuns.get(runId)?.controller.signal;
214
- }
215
-
216
219
  getClientId(runId: string): string | undefined {
217
220
  return this._activeRuns.get(runId)?.clientId;
218
221
  }
219
222
 
220
- cancel(runId: string): void {
221
- this._logger?.debug('DefaultRunManager.cancel();', { runId });
222
- this._activeRuns.get(runId)?.controller.abort();
223
- }
224
-
225
- getActiveRunIds(): string[] {
226
- return [...this._activeRuns.keys()];
227
- }
228
-
229
223
  close(): void {
230
224
  this._logger?.trace('DefaultRunManager.close();', { activeRuns: this._activeRuns.size });
231
225
  for (const state of this._activeRuns.values()) {