@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,738 @@
1
+ /**
2
+ * AgentView — internal, server-side message-loading + input-event lookup for
3
+ * AgentSession.
4
+ *
5
+ * Encapsulates everything the agent needs to read conversation state off the
6
+ * channel: locating the triggering input event before `run-start`
7
+ * ({@link AgentView.findInputEvent}), and reconstructing the ancestor chain for
8
+ * an LLM prompt ({@link AgentView.loadConversation} / {@link AgentView.messages}).
9
+ *
10
+ * It does NOT own the materialisation Tree — AgentSession owns the Tree and the
11
+ * applier (and swaps them on channel continuity loss) and injects them here as
12
+ * `readonly` fields, the same way ClientSession wires `DefaultView`. Because
13
+ * AgentSession swaps the Tree, it RECREATES the AgentView on continuity loss
14
+ * (a fresh instance bound to the fresh Tree/applier) rather than mutating it —
15
+ * so this class never needs a tree accessor or a reset hook.
16
+ *
17
+ * This is deliberately internal: it is not exported from any entry point and
18
+ * does NOT implement the public `View` interface (that is the client-side
19
+ * `DefaultView`, unrelated to this class).
20
+ *
21
+ * Both `findInputEvent` and `loadConversation` drive ONE history-walk mechanism
22
+ * — the single-flight chain in {@link AgentView._driveHistoryChain} — so a
23
+ * `start()` input scan and a concurrent `loadConversation` share folded pages
24
+ * instead of each scanning the channel.
25
+ */
26
+
27
+ import * as Ably from 'ably';
28
+
29
+ import { HEADER_EVENT_ID } from '../../constants.js';
30
+ import { ErrorCode } from '../../errors.js';
31
+ import type { Logger } from '../../logger.js';
32
+ import { compareBySerial, errorCause, errorMessage, getTransportHeaders } from '../../utils.js';
33
+ import type { Codec, CodecInputEvent, CodecOutputEvent } from '../codec/types.js';
34
+ import type { WireApplier } from './decode-fold.js';
35
+ import { type HistoryPagesCursor, loadHistoryPages } from './load-history-pages.js';
36
+ import type { TreeInternal } from './tree.js';
37
+ import type { ConversationNode, Tree } from './types.js';
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Input-event lookup result
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Result of {@link AgentView.findInputEvent}. The lookup races the session's
45
+ * Tree (`findAblyMessageByEventId` pre-scan + `'ably-message'` event for live
46
+ * arrivals) against a bounded history scan; resolves with the matched messages
47
+ * sorted by Ably `serial` ascending.
48
+ *
49
+ * Run.start reads `firstHeaders` / `firstClientId` from the smallest-serial
50
+ * matched message to derive per-run metadata (run-id, parent, forkOf,
51
+ * continuation flag, publisher clientId). The Tree has already folded each
52
+ * message by the time the lookup resolves, so callers do NOT need to decode the
53
+ * raw matched messages themselves.
54
+ */
55
+ export interface InputEventLookupResult {
56
+ /** Raw Ably messages matched by the lookup, sorted by serial ascending. */
57
+ rawMessages: Ably.InboundMessage[];
58
+ /** Transport headers of the smallest-serial matched message (run metadata). */
59
+ firstHeaders?: Record<string, string>;
60
+ /** Publisher's Ably channel-level `clientId` from the smallest-serial message. */
61
+ firstClientId?: string;
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Ancestor-chain walk over the Tree
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /**
69
+ * Walk parent pointers from an anchor codec-message-id back through the
70
+ * Tree to the conversation root, returning nodes in root-first order. When
71
+ * `maxRuns` is set, the walk stops before the RunNode that would exceed the
72
+ * bound, so the bounding run's own input node(s) are still included (input
73
+ * nodes never count toward the bound). The chain therefore starts with the
74
+ * input that triggered its oldest run, never with an assistant reply.
75
+ *
76
+ * Returns an empty array when the anchor isn't in the Tree.
77
+ * @param tree - The materialisation tree to walk.
78
+ * @param anchor - The codec-message-id to start from (typically the current run's input).
79
+ * @param maxRuns - Optional bound on the number of ancestor reply RunNodes in the chain.
80
+ * @param currentRunId - The current run's id. Its own RunNode (reachable when
81
+ * the anchor's wire carried the run-id) is conversation tail, not ancestor
82
+ * context, so it never counts toward `maxRuns`.
83
+ * @returns Nodes from root to anchor in chronological order.
84
+ */
85
+ export const walkAncestorChain = <TOutput extends CodecOutputEvent, TProjection>(
86
+ tree: Tree<TOutput, TProjection>,
87
+ anchor: string | undefined,
88
+ maxRuns?: number,
89
+ currentRunId?: string,
90
+ ): readonly ConversationNode<TProjection>[] => {
91
+ if (anchor === undefined) return [];
92
+ const chain: ConversationNode<TProjection>[] = [];
93
+ let current = tree.getNodeByCodecMessageId(anchor);
94
+ const seen = new Set<string>();
95
+ let runs = 0;
96
+ while (current !== undefined) {
97
+ // Defensive cycle guard — `parentCodecMessageId` chains should be DAGs;
98
+ // a cycle indicates Tree corruption but we don't want to infinite-loop.
99
+ const key = current.kind === 'run' ? current.runId : current.codecMessageId;
100
+ if (seen.has(key)) break;
101
+ if (current.kind === 'run' && current.runId !== currentRunId) {
102
+ // Stop before a run that would exceed the bound — the input node(s)
103
+ // above the last in-bound run belong to its turn and stay included.
104
+ if (maxRuns !== undefined && runs >= maxRuns) break;
105
+ runs += 1;
106
+ }
107
+ seen.add(key);
108
+ chain.unshift(current);
109
+ const parentId = current.parentCodecMessageId;
110
+ if (parentId === undefined) break;
111
+ current = tree.getNodeByCodecMessageId(parentId);
112
+ }
113
+ return chain;
114
+ };
115
+
116
+ /**
117
+ * Count the ancestor reply RunNodes in a chain. Used to bound the walk via
118
+ * the `maxRuns` option; the current run's own node never counts.
119
+ * @param chain - Ancestor chain to count over.
120
+ * @param currentRunId - The current run's id, excluded from the count.
121
+ * @returns Number of ancestor reply RunNodes in the chain.
122
+ */
123
+ const countReplyRuns = <TProjection>(
124
+ chain: readonly ConversationNode<TProjection>[],
125
+ currentRunId?: string,
126
+ ): number => {
127
+ let count = 0;
128
+ for (const node of chain) if (node.kind === 'run' && node.runId !== currentRunId) count++;
129
+ return count;
130
+ };
131
+
132
+ /**
133
+ * Wrap an unknown history-walk failure as `Ably.ErrorInfo`, preserving the
134
+ * original code/statusCode when the failure already carried them and
135
+ * attaching the original as `cause`. Falls back to `HistoryFetchFailed`.
136
+ * @param operation - The failed operation, phrased for an `unable to <operation>; <reason>` message.
137
+ * @param error - The thrown value.
138
+ * @returns The wrapped error.
139
+ */
140
+ const wrapHistoryError = (operation: string, error: unknown): Ably.ErrorInfo => {
141
+ const errInfo = errorCause(error);
142
+ return new Ably.ErrorInfo(
143
+ `unable to ${operation}; ${errorMessage(error)}`,
144
+ errInfo?.code ?? ErrorCode.HistoryFetchFailed,
145
+ errInfo?.statusCode ?? 500,
146
+ errInfo,
147
+ );
148
+ };
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Options
152
+ // ---------------------------------------------------------------------------
153
+
154
+ /**
155
+ * Constructor dependencies for {@link AgentView}, injected by AgentSession.
156
+ *
157
+ * AgentView holds `tree` + `applier` directly (like `DefaultView`). AgentSession
158
+ * owns them and, because it SWAPS the Tree on continuity loss, recreates the
159
+ * AgentView with the fresh Tree/applier rather than mutating them in place.
160
+ */
161
+ export interface AgentViewOptions<
162
+ TInput extends CodecInputEvent,
163
+ TOutput extends CodecOutputEvent,
164
+ TProjection,
165
+ TMessage,
166
+ > {
167
+ /** The session's materialisation Tree (read for walks; folded into by history). */
168
+ tree: TreeInternal<TInput, TOutput, TProjection>;
169
+ /** The Ably channel to read history from. */
170
+ channel: Ably.RealtimeChannel;
171
+ /** Codec used to project per-node messages. */
172
+ codec: Codec<TInput, TOutput, TProjection, TMessage>;
173
+ /** The Tree's decode-and-apply engine; history pages fold through it. */
174
+ applier: WireApplier;
175
+ /** Logger for diagnostic output. */
176
+ logger?: Logger;
177
+ /**
178
+ * Age bound for the input-event scan: the scan gives up paging once it
179
+ * crosses `Date.now() - inputEventLookbackMs`. Applied only to
180
+ * `findInputEvent`, never to the ancestor-hydration walk.
181
+ */
182
+ inputEventLookbackMs: number;
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Implementation
187
+ // ---------------------------------------------------------------------------
188
+
189
+ /**
190
+ * Internal server-side view: input-event lookup + conversation loading over the
191
+ * session Tree. See the file header for the ownership boundary.
192
+ */
193
+ export class AgentView<TInput extends CodecInputEvent, TOutput extends CodecOutputEvent, TProjection, TMessage> {
194
+ private readonly _tree: TreeInternal<TInput, TOutput, TProjection>;
195
+ private readonly _channel: Ably.RealtimeChannel;
196
+ private readonly _codec: Codec<TInput, TOutput, TProjection, TMessage>;
197
+ private readonly _applier: WireApplier;
198
+ private readonly _logger?: Logger;
199
+ private readonly _inputEventLookbackMs: number;
200
+
201
+ /**
202
+ * Tail of the single-flight history-hydration chain. Each walk links behind
203
+ * the current tail and becomes the new tail, so concurrent calls serialise
204
+ * and share each other's folded pages instead of each scanning the channel.
205
+ * A link never rejects (it records its error locally), so a follower awaiting
206
+ * the tail is isolated from a prior link's failure.
207
+ */
208
+ private _hydrationMutex: Promise<void> | undefined;
209
+ /**
210
+ * Shared history-walk cursor for this AgentView's attach epoch — ONE backward
211
+ * `untilAttach` pagination that both `findInputEvent` and `loadConversation`
212
+ * advance. `findInputEvent` pages it until the trigger is found (or its
213
+ * lookback give-up point) and pauses; `loadConversation` resumes from that
214
+ * position instead of re-paging from newest, so the channel is walked once.
215
+ * Created lazily on first use (no per-caller signal, so it outlives any one
216
+ * caller; no lookback, so it can reach attach). The single-flight chain
217
+ * (`_hydrationMutex`) serialises access so it is never paged concurrently. A
218
+ * continuity-loss swap recreates the whole AgentView, so there is no in-place
219
+ * reset.
220
+ */
221
+ private _cursor: HistoryPagesCursor | undefined;
222
+ /**
223
+ * True once the shared cursor reached attach (channel exhausted). Because the
224
+ * cursor carries no lookback, its exhaustion is always genuine (never a
225
+ * lookback boundary), so either caller may record it; a lookback-bounded
226
+ * `findInputEvent` scan stops via an early `break` that leaves the cursor
227
+ * non-exhausted, so it never sets this.
228
+ */
229
+ private _historyExhausted = false;
230
+
231
+ constructor(options: AgentViewOptions<TInput, TOutput, TProjection, TMessage>) {
232
+ this._tree = options.tree;
233
+ this._channel = options.channel;
234
+ this._codec = options.codec;
235
+ this._applier = options.applier;
236
+ this._inputEventLookbackMs = options.inputEventLookbackMs;
237
+ this._logger = options.logger?.withContext({ component: 'AgentView' });
238
+ }
239
+
240
+ /**
241
+ * Fold a single wire message into the Tree: decode-and-apply via the applier,
242
+ * then notify Tree subscribers and populate the event-id index. Mirrors
243
+ * AgentSession's live `_foldWire`; history pages fold through this.
244
+ * @param wire - The inbound Ably message to fold.
245
+ */
246
+ private _foldWire(wire: Ably.InboundMessage): void {
247
+ this._applier.apply(wire);
248
+ this._tree.emitAblyMessage(wire);
249
+ }
250
+
251
+ // -------------------------------------------------------------------------
252
+ // Input-event lookup
253
+ // -------------------------------------------------------------------------
254
+
255
+ /**
256
+ * Find every message whose `event-id` matches one of `expectedEventIds`,
257
+ * racing three sources:
258
+ *
259
+ * 1. A pre-scan of the Tree via `findAblyMessageByEventId` for messages already
260
+ * folded into it from prior live arrivals.
261
+ * 2. A live listener on the Tree's `ably-message` event for new arrivals
262
+ * during the call.
263
+ * 3. The shared history walk (lookback-bounded) — pages fold into the Tree
264
+ * and surface through the same `ably-message` event.
265
+ *
266
+ * Resolves when every expected event-id has been matched. Per-id race
267
+ * resolution — whichever source surfaces a matched message first wins
268
+ * (dedup by serial). On timeout: cancels the in-flight history scan and
269
+ * rejects with `InputEventNotFound`, wrapping any history-scan failure as
270
+ * `cause` so a broken history fetch isn't masked behind the timeout. On
271
+ * signal abort: rejects with `InvalidArgument`.
272
+ *
273
+ * `firstHeaders` and `firstClientId` are read from the matched message with
274
+ * the smallest serial (`compareBySerial`), giving stable run-level
275
+ * metadata regardless of arrival ordering across sources.
276
+ * @param opts - Lookup parameters.
277
+ * @param opts.invocationId - The invocation id this lookup is for (logging / error messages).
278
+ * @param opts.runId - The run id this lookup is for (logging / error messages).
279
+ * @param opts.expectedEventIds - The set of `event-id`s the lookup must observe before resolving.
280
+ * @param opts.timeoutMs - Maximum total wait across live + history sources.
281
+ * @param opts.signal - AbortSignal that aborts the lookup if the run is cancelled.
282
+ * @returns Raw matched Ably messages sorted by serial ascending, plus the
283
+ * smallest-serial message's headers and clientId for downstream metadata.
284
+ */
285
+ // eslint-disable-next-line @typescript-eslint/promise-function-async -- the body IS a Promise executor; async would double-wrap it
286
+ findInputEvent(opts: {
287
+ invocationId: string;
288
+ runId: string;
289
+ expectedEventIds: readonly string[];
290
+ timeoutMs: number;
291
+ signal: AbortSignal;
292
+ }): Promise<InputEventLookupResult> {
293
+ const { invocationId, runId, expectedEventIds, timeoutMs, signal } = opts;
294
+ const logger = this._logger;
295
+ const expectedSet = new Set(expectedEventIds);
296
+ const expectedCount = expectedSet.size;
297
+
298
+ const matchedByEventId = new Map<string, Ably.InboundMessage>();
299
+
300
+ // Bounded history fetch in parallel with the live wait; this controller
301
+ // lets the lookup cancel the in-flight fetch on timeout / abort,
302
+ // independently of the run signal.
303
+ const historyController = new AbortController();
304
+
305
+ return new Promise<InputEventLookupResult>((resolve, reject) => {
306
+ let settled = false;
307
+ // A genuine history-scan failure (not a cancel-induced abort) recorded
308
+ // so the timeout rejection can surface it as `cause` — the live path
309
+ // may still win the race, so the failure alone doesn't reject.
310
+ let historyError: Ably.ErrorInfo | undefined;
311
+ /* eslint-disable prefer-const -- forward-declared so cleanup() / onCancelled() can reference before the listener register or the timeout schedule has run. */
312
+ let unregisterLive: (() => void) | undefined;
313
+ let timer: ReturnType<typeof setTimeout> | number | undefined;
314
+ /* eslint-enable */
315
+
316
+ const cleanup = (): void => {
317
+ if (unregisterLive) unregisterLive();
318
+ if (timer !== undefined) clearTimeout(timer);
319
+ historyController.abort();
320
+ signal.removeEventListener('abort', onCancelled);
321
+ };
322
+
323
+ const onCancelled = (): void => {
324
+ if (settled) return;
325
+ settled = true;
326
+ cleanup();
327
+ reject(
328
+ new Ably.ErrorInfo(
329
+ `unable to look up input event; run ${runId} was cancelled`,
330
+ ErrorCode.InvalidArgument,
331
+ 400,
332
+ ),
333
+ );
334
+ };
335
+
336
+ const finishOk = (): void => {
337
+ if (settled) return;
338
+ settled = true;
339
+ cleanup();
340
+ // Sort matched messages by serial for deterministic publish-order
341
+ // delivery to the caller — firstHeaders / firstClientId come from
342
+ // the smallest-serial message.
343
+ const sorted = [...matchedByEventId.values()].toSorted(compareBySerial);
344
+ let firstHeaders: Record<string, string> | undefined;
345
+ let firstClientId: string | undefined;
346
+ for (const m of sorted) {
347
+ if (firstHeaders === undefined) {
348
+ firstHeaders = getTransportHeaders(m);
349
+ firstClientId = m.clientId;
350
+ break;
351
+ }
352
+ }
353
+ logger?.debug('AgentView.findInputEvent(); collected input events', {
354
+ runId,
355
+ invocationId,
356
+ count: sorted.length,
357
+ });
358
+ resolve({ rawMessages: sorted, firstHeaders, firstClientId });
359
+ };
360
+
361
+ // Consider a message for matching against the expected set; returns true
362
+ // when the lookup is now fully satisfied.
363
+ const consider = (m: Ably.InboundMessage): boolean => {
364
+ if (settled) return false;
365
+ const headers = getTransportHeaders(m);
366
+ const eventId = headers[HEADER_EVENT_ID];
367
+ if (!eventId || !expectedSet.has(eventId) || matchedByEventId.has(eventId)) return false;
368
+ matchedByEventId.set(eventId, m);
369
+ return matchedByEventId.size >= expectedCount;
370
+ };
371
+
372
+ signal.addEventListener('abort', onCancelled, { once: true });
373
+ if (signal.aborted) {
374
+ onCancelled();
375
+ return;
376
+ }
377
+
378
+ // 1. Pre-scan the Tree's event-id index for already-folded matches.
379
+ // Multi-run sessions where a prior run folded the message hit here
380
+ // synchronously.
381
+ for (const id of expectedEventIds) {
382
+ const ablyMessage = this._tree.findAblyMessageByEventId(id);
383
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- settled may mutate via synchronous callbacks during consider()
384
+ if (ablyMessage && consider(ablyMessage) && !settled) {
385
+ finishOk();
386
+ return;
387
+ }
388
+ }
389
+
390
+ // 2. Subscribe to the Tree's `ably-message` event for live arrivals.
391
+ // The applier folds first; `emitAblyMessage` notifies subscribers
392
+ // AND populates the event-id index. Wires fed in by the parallel
393
+ // history fetch flow through the same event so the listener picks
394
+ // them up uniformly.
395
+ unregisterLive = this._tree.on('ably-message', (msg) => {
396
+ if (consider(msg) && !settled) finishOk();
397
+ });
398
+
399
+ // 3. Drive the shared history walk in parallel, lookback-bounded so the
400
+ // scan gives up (pausing the cursor) once it pages past the window
401
+ // rather than walking the whole channel for a missing trigger. Each
402
+ // page folds into the Tree, triggering the listener above. The cursor
403
+ // stays paused at its position; a later loadConversation resumes it.
404
+ // The resolution is discarded — findInputEvent never records exhaustion
405
+ // (loadConversation does, if it drives the cursor to attach).
406
+ this._driveHistoryChain(
407
+ () => settled,
408
+ historyController.signal,
409
+ this._inputEventLookbackMs,
410
+ 'scan history for input event',
411
+ ).catch((error: unknown) => {
412
+ if (settled) return;
413
+ historyError =
414
+ error instanceof Ably.ErrorInfo ? error : wrapHistoryError('scan history for input event', error);
415
+ logger?.warn('AgentView.findInputEvent(); history scan failed (continuing on live path)', {
416
+ error: errorMessage(error),
417
+ });
418
+ });
419
+
420
+ // 4. Overall timeout — cancels the in-flight history fetch and
421
+ // rejects with InputEventNotFound.
422
+ timer = setTimeout(() => {
423
+ if (settled) return;
424
+ settled = true;
425
+ cleanup();
426
+ reject(
427
+ new Ably.ErrorInfo(
428
+ `unable to look up input event; received ${String(matchedByEventId.size)} of ${String(expectedCount)} input events for invocation ${invocationId} within ${String(timeoutMs)}ms`,
429
+ ErrorCode.InputEventNotFound,
430
+ 504,
431
+ historyError,
432
+ ),
433
+ );
434
+ }, timeoutMs);
435
+ // Node returns an unref-able Timeout; browsers return a number. Unref
436
+ // so a parked lookup cannot keep a Node process alive by itself.
437
+ if (typeof timer === 'object') timer.unref();
438
+ });
439
+ }
440
+
441
+ // -------------------------------------------------------------------------
442
+ // Conversation walk
443
+ // -------------------------------------------------------------------------
444
+
445
+ /**
446
+ * Reconstruct the conversation by walking the parent chain from the run's
447
+ * input node back to the conversation root, reading already-folded
448
+ * projections off the Tree's nodes.
449
+ *
450
+ * Hydrates the Tree as needed via the shared history walk
451
+ * ({@link AgentView._hydrateAncestors}), then concatenates
452
+ * `codec.getMessages(node.projection)` per node (root first) and appends the
453
+ * current run's projection at the tail.
454
+ * @param runId - The current run's id (for the tail run's projection lookup).
455
+ * @param assistantParentFallback - The current run's input node codec-message-id.
456
+ * @param signal - AbortSignal; rejects with InvalidArgument when aborted.
457
+ * @param maxRuns - Optional bound on the parent walk; counts reply RunNodes.
458
+ * @param runIdAdopted - True when the run-id came from outside (runtime
459
+ * override or continuation), so its node may exist in channel history;
460
+ * false for agent-minted ids, whose run-start only ever arrives via the
461
+ * live echo.
462
+ * @param regenerateTarget - The codec-message-id being regenerated, or
463
+ * undefined; the run that owns it is flattened only up to that message so
464
+ * the reconstructed history stops before the assistant message being
465
+ * replaced (which the model would otherwise reject).
466
+ * @returns The branch's messages (root-first) and the current run's projection.
467
+ */
468
+ async loadConversation(
469
+ runId: string,
470
+ assistantParentFallback: string | undefined,
471
+ signal: AbortSignal,
472
+ maxRuns: number | undefined,
473
+ runIdAdopted: boolean,
474
+ regenerateTarget?: string,
475
+ ): Promise<{ messages: TMessage[]; projection: TProjection }> {
476
+ if (signal.aborted) {
477
+ throw new Ably.ErrorInfo(
478
+ `unable to load conversation; run ${runId} was cancelled`,
479
+ ErrorCode.InvalidArgument,
480
+ 400,
481
+ );
482
+ }
483
+
484
+ await this._hydrateAncestors(runId, assistantParentFallback, signal, maxRuns, runIdAdopted);
485
+
486
+ return this._collectConversation(runId, assistantParentFallback, maxRuns, regenerateTarget);
487
+ }
488
+
489
+ /**
490
+ * Walk the parent chain from `anchor` over the current Tree and concatenate
491
+ * each node's projected messages (root-first), then append the current run's
492
+ * own messages when its RunNode isn't already on the chain. Shared by
493
+ * {@link AgentView.loadConversation} and {@link AgentView.messages}. Pure read
494
+ * over whatever is currently folded — no fetching.
495
+ * @param runId - The current run's id (for the tail run's projection lookup).
496
+ * @param anchor - The current run's input node codec-message-id.
497
+ * @param maxRuns - Optional bound on the ancestor walk (counts reply runs).
498
+ * @param regenerateTarget - The codec-message-id being regenerated; when set,
499
+ * the walk stops before that message (a regenerate of a non-head message
500
+ * anchors at the target's predecessor, so flattening its run whole would
501
+ * re-emit the target and end the history on the message being replaced).
502
+ * @returns The conversation messages (root-first) and the current run's
503
+ * projection (the codec's empty init when the run has no node yet).
504
+ */
505
+ private _collectConversation(
506
+ runId: string,
507
+ anchor: string | undefined,
508
+ maxRuns?: number,
509
+ regenerateTarget?: string,
510
+ ): { messages: TMessage[]; projection: TProjection } {
511
+ const tree = this._tree;
512
+ const chain = walkAncestorChain(tree, anchor, maxRuns, runId);
513
+ const runNode = tree.getRunNode(runId);
514
+ const messages: TMessage[] = [];
515
+ for (const node of chain) {
516
+ for (const m of this._codec.getMessages(node.projection)) {
517
+ if (regenerateTarget !== undefined && m.codecMessageId === regenerateTarget) {
518
+ return { messages, projection: runNode?.projection ?? this._codec.init() };
519
+ }
520
+ messages.push(m.message);
521
+ }
522
+ }
523
+
524
+ if (runNode !== undefined && !chain.some((n) => n.kind === 'run' && n.runId === runId)) {
525
+ for (const m of this._codec.getMessages(runNode.projection)) {
526
+ messages.push(m.message);
527
+ }
528
+ }
529
+
530
+ return { messages, projection: runNode?.projection ?? this._codec.init() };
531
+ }
532
+
533
+ /**
534
+ * Synchronous live read of the conversation messages for `Run.messages`:
535
+ * walk the parent chain from `anchor` (no `maxRuns` bound), concatenate each
536
+ * ancestor's projection, then append the current run's messages if its node
537
+ * isn't already on the chain. No I/O — reflects whatever is currently folded.
538
+ * @param runId - The current run's id (for the tail run's projection lookup).
539
+ * @param anchor - The current run's input node codec-message-id (assistantParentFallback).
540
+ * @param regenerateTarget - The codec-message-id being regenerated; when set,
541
+ * the walk stops before it (see {@link AgentView._collectConversation}).
542
+ * @returns The conversation messages, root-first.
543
+ */
544
+ messages(runId: string, anchor: string | undefined, regenerateTarget?: string): TMessage[] {
545
+ return this._collectConversation(runId, anchor, undefined, regenerateTarget).messages;
546
+ }
547
+
548
+ // -------------------------------------------------------------------------
549
+ // Shared history walk
550
+ // -------------------------------------------------------------------------
551
+
552
+ /**
553
+ * Single-flight chain entry shared by `findInputEvent` and `loadConversation`.
554
+ * Serialises behind any in-flight walk so the shared cursor is advanced by one
555
+ * caller at a time (never paged concurrently), then runs one
556
+ * {@link AgentView._walkSharedHistory}. A link never rejects (it records its
557
+ * error locally), so a follower awaiting the chain tail is isolated from a
558
+ * prior link's failure; this method rethrows the wrapped error from its own
559
+ * frame after awaiting.
560
+ *
561
+ * Returns `exhausted` but never records `_historyExhausted`; the caller records
562
+ * it (both callers may, since the shared cursor's exhaustion is always genuine
563
+ * — see {@link AgentView._historyExhausted}).
564
+ * @param shouldStop - Polled before each page; true pauses this walk.
565
+ * @param signal - Per-call abort signal (checked between pages).
566
+ * @param lookbackMs - Optional give-up bound for the input scan (early break).
567
+ * @param operationLabel - Verb for the wrapped error message.
568
+ * @returns `{ exhausted }` — true only when the shared cursor reached attach.
569
+ */
570
+ private async _driveHistoryChain(
571
+ shouldStop: () => boolean,
572
+ signal: AbortSignal,
573
+ lookbackMs: number | undefined,
574
+ operationLabel: string,
575
+ ): Promise<{ exhausted: boolean }> {
576
+ let exhausted = false;
577
+ let fetchError: Ably.ErrorInfo | undefined;
578
+ const prev = this._hydrationMutex ?? Promise.resolve();
579
+ const mine = (async (): Promise<void> => {
580
+ await prev.catch(() => {
581
+ /* a prior link's failure is its own to throw; this link fetches independently */
582
+ });
583
+ if (this._historyExhausted || signal.aborted || shouldStop()) return;
584
+ try {
585
+ exhausted = await this._walkSharedHistory(shouldStop, signal, lookbackMs);
586
+ } catch (error) {
587
+ fetchError = wrapHistoryError(operationLabel, error);
588
+ }
589
+ })();
590
+ this._hydrationMutex = mine;
591
+ await mine;
592
+ if (fetchError !== undefined) throw fetchError;
593
+ return { exhausted };
594
+ }
595
+
596
+ /**
597
+ * Advance the SHARED history cursor (lazily opening it once per attach epoch)
598
+ * and fold each page into the session Tree via the injected `fold`, stopping
599
+ * when `shouldStop()` returns true, the channel is exhausted, the signal
600
+ * aborts, a continuity-loss Tree swap abandons the walk, or — when `lookbackMs`
601
+ * is given — the walk pages past the lookback window. The cursor is NOT closed
602
+ * on stop: it stays paused at its current position so a later caller resumes
603
+ * from there rather than re-paging from newest. Throws (caller-wrapped) on a
604
+ * fetch failure after `loadHistoryPages`' per-page retries.
605
+ * @param shouldStop - Polled before each page; true pauses the walk.
606
+ * @param signal - Per-call abort signal (checked between pages; the shared cursor carries none).
607
+ * @param lookbackMs - Optional give-up bound: stop paging once a page's oldest
608
+ * message predates `Date.now() - lookbackMs`. An early `break`, NOT a cursor
609
+ * bound, so the cursor stays resumable and exhaustion is never reported here.
610
+ * @returns True only when the cursor genuinely reached attach — NOT when
611
+ * paused by the predicate / lookback, a Tree swap, or signal abort.
612
+ */
613
+ private async _walkSharedHistory(
614
+ shouldStop: () => boolean,
615
+ signal: AbortSignal,
616
+ lookbackMs?: number,
617
+ ): Promise<boolean> {
618
+ if (this._cursor === undefined) {
619
+ this._cursor = await loadHistoryPages(this._channel, {
620
+ pageLimit: 200,
621
+ untilAttach: true,
622
+ logger: this._logger,
623
+ });
624
+ }
625
+ const cursor = this._cursor;
626
+ while (cursor.hasNext() && !shouldStop()) {
627
+ if (signal.aborted) return false;
628
+ const chunk = await cursor.next();
629
+ // `next()` returning undefined means the cursor is permanently spent
630
+ // (it has cleared its current page) — genuine exhaustion.
631
+ if (!chunk) break;
632
+ // Ably returns history pages newest-first; fold in chronological order so
633
+ // codec projections build oldest-to-newest (matches the live decode loop).
634
+ for (const wire of chunk.toReversed()) {
635
+ this._foldWire(wire);
636
+ }
637
+ // findInputEvent's give-up bound: once this page predates the lookback
638
+ // window, stop scanning. The cursor stays open (hasNext() still true), so
639
+ // loadConversation can resume past here and this never reports exhaustion.
640
+ if (lookbackMs !== undefined) {
641
+ const oldest = chunk.at(-1);
642
+ if (oldest?.timestamp !== undefined && oldest.timestamp < Date.now() - lookbackMs) break;
643
+ }
644
+ }
645
+ // Genuine exhaustion only: the cursor reached attach and the walk wasn't aborted.
646
+ return !cursor.hasNext() && !signal.aborted;
647
+ }
648
+
649
+ /**
650
+ * Populate the Tree with enough ancestor coverage to walk from `anchor` to
651
+ * root (or `maxRuns` reply runs back) by driving the shared history walk.
652
+ * Records `_historyExhausted` only when a FULL (no-lookback) walk genuinely
653
+ * exhausts the channel.
654
+ * @param runId - The current run's id (when adopted, its node must be present in the Tree before the walk is complete).
655
+ * @param anchor - The input codec-message-id to walk from. Undefined means no walk is needed (current run only).
656
+ * @param signal - AbortSignal.
657
+ * @param maxRuns - Optional bound on the ancestor walk.
658
+ * @param runIdAdopted - Whether the run-id came from outside (override or continuation) and so may name a run present in channel history.
659
+ * @throws {Ably.ErrorInfo} `InvalidArgument` when `signal` aborts;
660
+ * `HistoryFetchFailed` — or the underlying Ably code when the failure
661
+ * carried one — (original as `cause`) when this caller's own history
662
+ * fetch fails after retries.
663
+ */
664
+ private async _hydrateAncestors(
665
+ runId: string,
666
+ anchor: string | undefined,
667
+ signal: AbortSignal,
668
+ maxRuns: number | undefined,
669
+ runIdAdopted: boolean,
670
+ ): Promise<void> {
671
+ // Check whether the Tree already has what we need: the current run node
672
+ // exists AND (no anchor OR anchor's chain reaches root / maxRuns).
673
+ const needsFetch = (): boolean => {
674
+ const tree = this._tree;
675
+ // Only an adopted run-id (runtime override or continuation) can name a
676
+ // run already present in channel history. A fresh agent-minted run's
677
+ // run-start is published after attach, so the `untilAttach` walk can
678
+ // never surface it; demanding it would page the whole channel to
679
+ // exhaustion. Fresh runs are satisfied by start()'s optimistic insert.
680
+ // For adopted ids the node must be serial-CONFIRMED: an override id's
681
+ // optimistic insert is serial-less, and its history content (if any)
682
+ // still needs hydrating.
683
+ if (runIdAdopted && tree.getRunNode(runId)?.startSerial === undefined) return true;
684
+ if (anchor === undefined) return false;
685
+ if (tree.getNodeByCodecMessageId(anchor) === undefined) return true;
686
+ const chain = walkAncestorChain(tree, anchor, maxRuns, runId);
687
+ const head = chain[0];
688
+ const reachedRoot = head !== undefined && head.parentCodecMessageId === undefined;
689
+ // The bound is only satisfied once the bounding run's triggering input
690
+ // is in the chain — a head that is still an ancestor RunNode means the
691
+ // input above it hasn't been hydrated yet (assistant-first context).
692
+ const reachedLimit =
693
+ maxRuns !== undefined &&
694
+ countReplyRuns(chain, runId) >= maxRuns &&
695
+ head !== undefined &&
696
+ (head.kind !== 'run' || head.runId === runId);
697
+ return !reachedRoot && !reachedLimit;
698
+ };
699
+
700
+ // Already satisfied, or a prior full walk this epoch drove history to
701
+ // exhaustion (fetching again cannot reveal more) — nothing to do.
702
+ if (!needsFetch() || this._historyExhausted) return;
703
+
704
+ let exhausted: boolean;
705
+ try {
706
+ // Full walk — NO lookback — so an exhausted return is authoritative for
707
+ // the attach epoch and may be recorded.
708
+ ({ exhausted } = await this._driveHistoryChain(() => !needsFetch(), signal, undefined, 'hydrate ancestors'));
709
+ } catch (error) {
710
+ this._logger?.error('AgentView._hydrateAncestors(); history fetch failed', {
711
+ runId,
712
+ error: errorMessage(error),
713
+ });
714
+ throw error;
715
+ }
716
+ if (exhausted) this._historyExhausted = true;
717
+ // A between-pages abort unwinds the fold cleanly (no throw); surface it as
718
+ // the cancellation the caller expects rather than returning partial history.
719
+ if (signal.aborted && needsFetch()) {
720
+ throw new Ably.ErrorInfo('unable to hydrate ancestors; signal aborted', ErrorCode.InvalidArgument, 400);
721
+ }
722
+ }
723
+ }
724
+
725
+ /**
726
+ * Create an {@link AgentView}. Factory entry point mirroring `createTree`;
727
+ * AgentSession never calls `new AgentView` directly.
728
+ * @param options - Injected dependencies.
729
+ * @returns A new AgentView.
730
+ */
731
+ export const createAgentView = <
732
+ TInput extends CodecInputEvent,
733
+ TOutput extends CodecOutputEvent,
734
+ TProjection,
735
+ TMessage,
736
+ >(
737
+ options: AgentViewOptions<TInput, TOutput, TProjection, TMessage>,
738
+ ): AgentView<TInput, TOutput, TProjection, TMessage> => new AgentView(options);