@ably/ai-transport 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/README.md +91 -100
  2. package/dist/ably-ai-transport.js +1553 -1238
  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 +29 -0
  8. package/dist/core/codec/decoder.d.ts +20 -23
  9. package/dist/core/codec/encoder.d.ts +11 -8
  10. package/dist/core/codec/index.d.ts +1 -2
  11. package/dist/core/codec/lifecycle-tracker.d.ts +10 -9
  12. package/dist/core/codec/types.d.ts +407 -115
  13. package/dist/core/transport/agent-session.d.ts +10 -0
  14. package/dist/core/transport/branch-chain.d.ts +43 -0
  15. package/dist/core/transport/client-session.d.ts +13 -0
  16. package/dist/core/transport/decode-fold.d.ts +47 -0
  17. package/dist/core/transport/headers.d.ts +96 -18
  18. package/dist/core/transport/index.d.ts +5 -6
  19. package/dist/core/transport/internal/bounded-map.d.ts +20 -0
  20. package/dist/core/transport/invocation.d.ts +74 -0
  21. package/dist/core/transport/load-conversation.d.ts +128 -0
  22. package/dist/core/transport/load-history.d.ts +39 -0
  23. package/dist/core/transport/pipe-stream.d.ts +9 -9
  24. package/dist/core/transport/run-manager.d.ts +78 -0
  25. package/dist/core/transport/tree.d.ts +373 -109
  26. package/dist/core/transport/types/agent.d.ts +353 -0
  27. package/dist/core/transport/types/client.d.ts +168 -0
  28. package/dist/core/transport/types/shared.d.ts +24 -0
  29. package/dist/core/transport/types/tree.d.ts +315 -0
  30. package/dist/core/transport/types/view.d.ts +222 -0
  31. package/dist/core/transport/types.d.ts +13 -553
  32. package/dist/core/transport/view.d.ts +272 -84
  33. package/dist/errors.d.ts +21 -10
  34. package/dist/index.d.ts +6 -8
  35. package/dist/logger.d.ts +12 -0
  36. package/dist/react/ably-ai-transport-react.js +976 -990
  37. package/dist/react/ably-ai-transport-react.js.map +1 -1
  38. package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
  39. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
  40. package/dist/react/contexts/client-session-context.d.ts +36 -0
  41. package/dist/react/contexts/client-session-provider.d.ts +53 -0
  42. package/dist/react/create-session-hooks.d.ts +116 -0
  43. package/dist/react/index.d.ts +12 -12
  44. package/dist/react/internal/use-resolved-session.d.ts +36 -0
  45. package/dist/react/use-ably-messages.d.ts +17 -14
  46. package/dist/react/use-client-session.d.ts +81 -0
  47. package/dist/react/use-create-view.d.ts +14 -13
  48. package/dist/react/use-tree.d.ts +30 -15
  49. package/dist/react/use-view.d.ts +82 -51
  50. package/dist/utils.d.ts +32 -23
  51. package/dist/vercel/ably-ai-transport-vercel.js +2573 -2086
  52. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
  53. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
  54. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
  55. package/dist/vercel/codec/decoder.d.ts +5 -18
  56. package/dist/vercel/codec/encoder.d.ts +6 -36
  57. package/dist/vercel/codec/events.d.ts +51 -0
  58. package/dist/vercel/codec/index.d.ts +24 -12
  59. package/dist/vercel/codec/reducer.d.ts +144 -0
  60. package/dist/vercel/codec/tool-transitions.d.ts +2 -2
  61. package/dist/vercel/index.d.ts +4 -5
  62. package/dist/vercel/react/ably-ai-transport-vercel-react.js +3907 -3266
  63. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
  64. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +33 -8
  65. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
  66. package/dist/vercel/react/contexts/chat-transport-context.d.ts +7 -6
  67. package/dist/vercel/react/contexts/chat-transport-provider.d.ts +53 -41
  68. package/dist/vercel/react/index.d.ts +1 -2
  69. package/dist/vercel/react/use-chat-transport.d.ts +30 -26
  70. package/dist/vercel/react/use-message-sync.d.ts +17 -30
  71. package/dist/vercel/run-end-reason.d.ts +29 -0
  72. package/dist/vercel/transport/chat-transport.d.ts +43 -24
  73. package/dist/vercel/transport/index.d.ts +25 -21
  74. package/dist/vercel/transport/run-output-stream.d.ts +56 -0
  75. package/dist/version.d.ts +2 -0
  76. package/package.json +30 -23
  77. package/src/constants.ts +124 -51
  78. package/src/core/agent.ts +68 -0
  79. package/src/core/codec/decoder.ts +71 -98
  80. package/src/core/codec/encoder.ts +113 -65
  81. package/src/core/codec/index.ts +13 -6
  82. package/src/core/codec/lifecycle-tracker.ts +10 -9
  83. package/src/core/codec/types.ts +436 -120
  84. package/src/core/transport/agent-session.ts +1344 -0
  85. package/src/core/transport/branch-chain.ts +58 -0
  86. package/src/core/transport/client-session.ts +775 -0
  87. package/src/core/transport/decode-fold.ts +91 -0
  88. package/src/core/transport/headers.ts +181 -22
  89. package/src/core/transport/index.ts +25 -26
  90. package/src/core/transport/internal/bounded-map.ts +27 -0
  91. package/src/core/transport/invocation.ts +98 -0
  92. package/src/core/transport/load-conversation.ts +355 -0
  93. package/src/core/transport/load-history.ts +269 -0
  94. package/src/core/transport/pipe-stream.ts +54 -39
  95. package/src/core/transport/run-manager.ts +249 -0
  96. package/src/core/transport/tree.ts +926 -308
  97. package/src/core/transport/types/agent.ts +407 -0
  98. package/src/core/transport/types/client.ts +211 -0
  99. package/src/core/transport/types/shared.ts +27 -0
  100. package/src/core/transport/types/tree.ts +344 -0
  101. package/src/core/transport/types/view.ts +259 -0
  102. package/src/core/transport/types.ts +13 -706
  103. package/src/core/transport/view.ts +864 -433
  104. package/src/errors.ts +22 -9
  105. package/src/event-emitter.ts +3 -2
  106. package/src/index.ts +52 -41
  107. package/src/logger.ts +14 -1
  108. package/src/react/contexts/client-session-context.ts +41 -0
  109. package/src/react/contexts/client-session-provider.tsx +186 -0
  110. package/src/react/create-session-hooks.ts +141 -0
  111. package/src/react/index.ts +23 -13
  112. package/src/react/internal/use-resolved-session.ts +63 -0
  113. package/src/react/use-ably-messages.ts +32 -22
  114. package/src/react/use-client-session.ts +201 -0
  115. package/src/react/use-create-view.ts +33 -29
  116. package/src/react/use-tree.ts +61 -30
  117. package/src/react/use-view.ts +139 -97
  118. package/src/utils.ts +63 -45
  119. package/src/vercel/codec/decoder.ts +336 -258
  120. package/src/vercel/codec/encoder.ts +343 -205
  121. package/src/vercel/codec/events.ts +87 -0
  122. package/src/vercel/codec/index.ts +60 -13
  123. package/src/vercel/codec/reducer.ts +977 -0
  124. package/src/vercel/codec/tool-transitions.ts +2 -2
  125. package/src/vercel/index.ts +6 -19
  126. package/src/vercel/react/contexts/chat-transport-context.ts +7 -6
  127. package/src/vercel/react/contexts/chat-transport-provider.tsx +87 -59
  128. package/src/vercel/react/index.ts +3 -5
  129. package/src/vercel/react/use-chat-transport.ts +47 -49
  130. package/src/vercel/react/use-message-sync.ts +80 -39
  131. package/src/vercel/run-end-reason.ts +78 -0
  132. package/src/vercel/transport/chat-transport.ts +392 -98
  133. package/src/vercel/transport/index.ts +39 -38
  134. package/src/vercel/transport/run-output-stream.ts +170 -0
  135. package/src/version.ts +2 -0
  136. package/dist/core/transport/client-transport.d.ts +0 -10
  137. package/dist/core/transport/decode-history.d.ts +0 -43
  138. package/dist/core/transport/server-transport.d.ts +0 -7
  139. package/dist/core/transport/stream-router.d.ts +0 -29
  140. package/dist/core/transport/turn-manager.d.ts +0 -37
  141. package/dist/react/contexts/transport-context.d.ts +0 -31
  142. package/dist/react/contexts/transport-provider.d.ts +0 -49
  143. package/dist/react/create-transport-hooks.d.ts +0 -124
  144. package/dist/react/use-active-turns.d.ts +0 -12
  145. package/dist/react/use-client-transport.d.ts +0 -80
  146. package/dist/vercel/codec/accumulator.d.ts +0 -21
  147. package/dist/vercel/react/use-staged-add-tool-approval-response.d.ts +0 -30
  148. package/dist/vercel/tool-approvals.d.ts +0 -124
  149. package/dist/vercel/tool-events.d.ts +0 -26
  150. package/src/core/transport/client-transport.ts +0 -977
  151. package/src/core/transport/decode-history.ts +0 -485
  152. package/src/core/transport/server-transport.ts +0 -612
  153. package/src/core/transport/stream-router.ts +0 -136
  154. package/src/core/transport/turn-manager.ts +0 -165
  155. package/src/react/contexts/transport-context.ts +0 -37
  156. package/src/react/contexts/transport-provider.tsx +0 -164
  157. package/src/react/create-transport-hooks.ts +0 -144
  158. package/src/react/use-active-turns.ts +0 -72
  159. package/src/react/use-client-transport.ts +0 -197
  160. package/src/vercel/codec/accumulator.ts +0 -588
  161. package/src/vercel/react/use-staged-add-tool-approval-response.ts +0 -87
  162. package/src/vercel/tool-approvals.ts +0 -380
  163. package/src/vercel/tool-events.ts +0 -53
@@ -0,0 +1,1344 @@
1
+ /**
2
+ * Core agent (server-side) session, parameterized by codec.
3
+ *
4
+ * Composes RunManager and pipeStream to handle the full server-side run
5
+ * lifecycle. Cancel message routing is handled directly by the session's
6
+ * single channel subscription — no separate cancel manager needed.
7
+ *
8
+ * The session exposes a single factory method — `createRun()` — which returns
9
+ * a Run object with explicit lifecycle methods: start(), pipe(), addEvents(),
10
+ * suspend(), and end() (suspend() and end() are both terminal).
11
+ */
12
+
13
+ import * as Ably from 'ably';
14
+
15
+ import {
16
+ EVENT_CANCEL,
17
+ HEADER_CODEC_MESSAGE_ID,
18
+ HEADER_EVENT_ID,
19
+ HEADER_FORK_OF,
20
+ HEADER_INPUT_CODEC_MESSAGE_ID,
21
+ HEADER_MSG_REGENERATE,
22
+ HEADER_PARENT,
23
+ HEADER_RUN_CLIENT_ID,
24
+ HEADER_RUN_ID,
25
+ } from '../../constants.js';
26
+ import { ErrorCode } from '../../errors.js';
27
+ import type { Logger } from '../../logger.js';
28
+ import { compareBySerial, getTransportHeaders } from '../../utils.js';
29
+ import { registerAgent } from '../agent.js';
30
+ import type { Codec, CodecInputEvent, CodecOutputEvent } from '../codec/types.js';
31
+ import { buildTransportHeaders } from './headers.js';
32
+ import { evictOldestIfFull } from './internal/bounded-map.js';
33
+ import { Invocation } from './invocation.js';
34
+ import { loadConversation, loadRunProjection } from './load-conversation.js';
35
+ import { pipeStream } from './pipe-stream.js';
36
+ import type { RunManager } from './run-manager.js';
37
+ import { createRunManager } from './run-manager.js';
38
+ import type {
39
+ AgentSession,
40
+ AgentSessionOptions,
41
+ CancelRequest,
42
+ EventsNode,
43
+ LoadConversationOptions,
44
+ MessageNode,
45
+ PipeOptions,
46
+ Run,
47
+ RunEndReason,
48
+ RunRuntime,
49
+ RunView,
50
+ StreamResult,
51
+ } from './types.js';
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Run-state lookup helpers
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /**
58
+ * Wait for every event-id in `expectedInputEventIds` to arrive as a channel
59
+ * message before letting the run proceed to LLM work. Uses the session's
60
+ * unfiltered channel dispatcher (registered in `connect()`) so that
61
+ * messages replayed via channel rewind on attach reach the lookup — no
62
+ * separate history fetch needed.
63
+ *
64
+ * Scope: this awaits the data-carrying input events a send publishes —
65
+ * fresh prompts, edits, regenerates, tool results, and approvals. Control
66
+ * events (cancel etc.) carry no `event-id`, are dispatched
67
+ * separately, and never enter this lookup.
68
+ *
69
+ * Each client-published event in a send (user-message AND amend events
70
+ * such as tool-approval responses and client tool outputs) is stamped
71
+ * with its own `event-id`.
72
+ * The lookup matches incoming messages against the expected set; ids
73
+ * not in the set are ignored, duplicates (rewind redelivering a message
74
+ * also seen live) are deduped by event-id. The wait completes when
75
+ * every expected id has arrived, guaranteeing the channel state is
76
+ * consistent with what the client promised before any downstream
77
+ * processing (loadProjection, streamText) runs.
78
+ *
79
+ * User-message arrivals decode into MessageNodes that populate
80
+ * `run.view.messages`; amend arrivals fold into a fresh projection that
81
+ * has no target message, so they're orphaned and dropped — they only
82
+ * count toward the wait. Collected nodes are returned sorted by Ably
83
+ * `serial` ascending.
84
+ *
85
+ * Bounded by `timeoutMs` as a total budget across all N arrivals. The
86
+ * caller's `signal` aborts the wait. On partial collection at timeout the
87
+ * promise rejects with `InputEventNotFound` and an error message including
88
+ * "received X of Y". If any decode throws mid-collection, the whole lookup
89
+ * rejects with `InputEventNotFound` wrapping the decode error as cause —
90
+ * already-collected messages are discarded.
91
+ * @param opts - Lookup parameters.
92
+ * @param opts.register - Session-provided registration that delivers the input events for the expected event-ids. Returns an unregister function.
93
+ * @param opts.codec - Codec used to decode arriving messages.
94
+ * @param opts.invocationId - Invocation identifier — used only for diagnostic logging and error messages.
95
+ * @param opts.runId - Run identifier (used for logging and error messages).
96
+ * @param opts.expectedInputEventIds - Input-event ids the lookup must observe before resolving.
97
+ * @param opts.timeoutMs - Maximum total time to wait for all event-id arrivals.
98
+ * @param opts.signal - AbortSignal that cancels the wait when the run is cancelled.
99
+ * @param opts.logger - Optional logger for diagnostic output.
100
+ * @returns The MessageNodes for arriving user-message events (sorted by Ably
101
+ * serial — empty when every input event was a tool-resolution wire message that
102
+ * decoded to a chunk and produced no node), and the transport headers of
103
+ * the first matched wire message. `firstHeaders` is the canonical source for
104
+ * run-level metadata (clientId, parent, forkOf, continuation flag) because
105
+ * it lands whether or not the decode produced a MessageNode. `firstClientId`
106
+ * carries the publisher's Ably-level `clientId` from that same message — the
107
+ * source of `inputClientId` re-stamping on the agent's published events.
108
+ */
109
+ interface InputEventLookupResult<TMessage> {
110
+ nodes: MessageNode<TMessage>[];
111
+ firstHeaders?: Record<string, string>;
112
+ firstClientId?: string;
113
+ /**
114
+ * Raw Ably messages observed live for the matched input-event ids, in
115
+ * arrival order. The agent forwards these to `loadRunProjection` so a
116
+ * continuation invocation can fold the just-published client wires
117
+ * (e.g. a tool-output-available) without waiting on Ably's channel
118
+ * history indexing window.
119
+ */
120
+ rawMessages: Ably.InboundMessage[];
121
+ }
122
+
123
+ const lookupInputEvents = async <
124
+ TInput extends CodecInputEvent,
125
+ TOutput extends CodecOutputEvent,
126
+ TProjection,
127
+ TMessage,
128
+ >(opts: {
129
+ register: (callback: (msg: Ably.InboundMessage) => void) => () => void;
130
+ codec: Codec<TInput, TOutput, TProjection, TMessage>;
131
+ invocationId: string;
132
+ runId: string;
133
+ expectedInputEventIds: readonly string[];
134
+ timeoutMs: number;
135
+ signal: AbortSignal;
136
+ logger: Logger | undefined;
137
+ }): Promise<InputEventLookupResult<TMessage>> => {
138
+ const { register, codec, invocationId, runId, expectedInputEventIds, timeoutMs, signal, logger } = opts;
139
+ const expectedSet = new Set(expectedInputEventIds);
140
+ const expectedCount = expectedSet.size;
141
+
142
+ const collected: MessageNode<TMessage>[] = [];
143
+ const rawMessages: Ably.InboundMessage[] = [];
144
+ const matchedInputEventIds = new Set<string>();
145
+ let firstHeaders: Record<string, string> | undefined;
146
+ let firstClientId: string | undefined;
147
+
148
+ /**
149
+ * Decode an inbound Ably message into MessageNodes via the codec.
150
+ * @param m - The inbound Ably message to decode.
151
+ * @returns The decoded MessageNodes carrying transport headers and serial.
152
+ */
153
+ const decode = (m: Ably.InboundMessage): MessageNode<TMessage>[] => {
154
+ const decoder = codec.createDecoder();
155
+ const headers = getTransportHeaders(m);
156
+ const codecMessageId = headers[HEADER_CODEC_MESSAGE_ID] ?? '';
157
+ const { inputs, outputs } = decoder.decode(m);
158
+ const events: (TInput | TOutput)[] = [...inputs, ...outputs];
159
+ let projection = codec.init();
160
+ for (const event of events) {
161
+ projection = codec.fold(projection, event, { serial: m.serial ?? '', messageId: codecMessageId });
162
+ }
163
+ return codec.getMessages(projection).map(({ message }) => ({
164
+ kind: 'message' as const,
165
+ message,
166
+ codecMessageId,
167
+ parentId: headers[HEADER_PARENT],
168
+ forkOf: headers[HEADER_FORK_OF],
169
+ headers,
170
+ serial: m.serial,
171
+ }));
172
+ };
173
+
174
+ return new Promise<InputEventLookupResult<TMessage>>((resolve, reject) => {
175
+ let settled = false;
176
+ // Dedupe across rewind-redelivery: rewind may surface a message the
177
+ // listener also saw live. Scoped to the active lookup so it cannot
178
+ // grow unbounded.
179
+ const seenSerials = new Set<string>();
180
+ // Forward-declared so that cleanup() and onCancelled() can reference them
181
+ // before they are assigned. cleanup may run synchronously inside
182
+ // `register(...)` (when buffered input events drain on registration) before
183
+ // `unregister`/`timer` have been assigned — the no-op fallback for
184
+ // unregister and undefined-guard for timer handle that window. The
185
+ // settled-flag re-check after `register` returns reconciles the
186
+ // listener-detach that cleanup couldn't perform inside that window.
187
+ /* eslint-disable prefer-const, unicorn/consistent-function-scoping, @typescript-eslint/no-empty-function -- forward-declared state for the sync-drain reconciliation pattern; see comment above. */
188
+ let unregister: () => void = () => {};
189
+ let timer: ReturnType<typeof setTimeout> | undefined;
190
+ /* eslint-enable */
191
+ const cleanup = (): void => {
192
+ unregister();
193
+ if (timer !== undefined) clearTimeout(timer);
194
+ signal.removeEventListener('abort', onCancelled);
195
+ };
196
+ const onCancelled = (): void => {
197
+ if (settled) return;
198
+ settled = true;
199
+ cleanup();
200
+ reject(
201
+ new Ably.ErrorInfo(`unable to look up input event; run ${runId} was cancelled`, ErrorCode.InvalidArgument, 400),
202
+ );
203
+ };
204
+ signal.addEventListener('abort', onCancelled, { once: true });
205
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- onCancelled may have settled the promise synchronously above when the signal was already aborted.
206
+ if (settled) return;
207
+ unregister = register((m) => {
208
+ if (settled) return;
209
+ if (m.serial !== undefined && seenSerials.has(m.serial)) return;
210
+ if (m.serial !== undefined) seenSerials.add(m.serial);
211
+
212
+ const wireHeaders = getTransportHeaders(m);
213
+
214
+ // Only count messages whose event-id is in the expected set.
215
+ const msgEventId = wireHeaders[HEADER_EVENT_ID];
216
+ if (!msgEventId || !expectedSet.has(msgEventId) || matchedInputEventIds.has(msgEventId)) return;
217
+ matchedInputEventIds.add(msgEventId);
218
+
219
+ // Capture the trigger event's headers AND its Ably channel-level `clientId`
220
+ // so run-level metadata (parent / forkOf / continuation flag from headers;
221
+ // `inputClientId` from the wire publisher) is available even when the decode
222
+ // produces zero MessageNodes — the case for continuation tool-resolution
223
+ // trigger events whose chunks fold into a fresh empty projection without
224
+ // an assistant to land on.
225
+ if (firstHeaders === undefined) {
226
+ firstHeaders = wireHeaders;
227
+ firstClientId = m.clientId;
228
+ }
229
+
230
+ let decoded: MessageNode<TMessage>[];
231
+ try {
232
+ decoded = decode(m);
233
+ } catch (error) {
234
+ settled = true;
235
+ cleanup();
236
+ const cause = error instanceof Ably.ErrorInfo ? error : undefined;
237
+ reject(
238
+ new Ably.ErrorInfo(
239
+ `unable to look up input event; decode failed for invocation ${invocationId}: ${error instanceof Error ? error.message : String(error)}`,
240
+ ErrorCode.InputEventNotFound,
241
+ 504,
242
+ cause,
243
+ ),
244
+ );
245
+ return;
246
+ }
247
+ for (const node of decoded) collected.push(node);
248
+ rawMessages.push(m);
249
+ if (matchedInputEventIds.size < expectedCount) return;
250
+ settled = true;
251
+ cleanup();
252
+ // Sort by Ably serial ascending so callers see publish order regardless
253
+ // of interleaved rewind+live delivery. Null serials sort last (defensive
254
+ // — input events should always carry a serial).
255
+ collected.sort(compareBySerial);
256
+ logger?.debug('lookupInputEvents(); collected input events', {
257
+ runId,
258
+ invocationId,
259
+ count: collected.length,
260
+ });
261
+ resolve({ nodes: collected, firstHeaders, firstClientId, rawMessages });
262
+ });
263
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the register callback may have settled the promise synchronously during buffered input-event drain.
264
+ if (settled) {
265
+ // Sync drain inside register settled the promise; cleanup ran but
266
+ // could not detach the listener because `unregister` was still the
267
+ // no-op. Detach it now.
268
+ unregister();
269
+ return;
270
+ }
271
+ timer = setTimeout(() => {
272
+ if (settled) return;
273
+ settled = true;
274
+ cleanup();
275
+ reject(
276
+ new Ably.ErrorInfo(
277
+ `unable to look up input event; received ${String(collected.length)} of ${String(expectedCount)} input events for invocation ${invocationId} within ${String(timeoutMs)}ms`,
278
+ ErrorCode.InputEventNotFound,
279
+ 504,
280
+ ),
281
+ );
282
+ }, timeoutMs);
283
+ });
284
+ };
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Internal run record for cancel routing
288
+ // ---------------------------------------------------------------------------
289
+
290
+ interface RegisteredRun {
291
+ runId: string;
292
+ /** Invocation-id this run is associated with, minted by the agent at `createRun` (or the `runtime.invocationId` override). */
293
+ invocationId: string;
294
+ controller: AbortController;
295
+ /** Composite signal that fires when either the internal controller or the external signal aborts. */
296
+ signal: AbortSignal;
297
+ onCancel?: (request: CancelRequest) => Promise<boolean>;
298
+ onError?: (error: Ably.ErrorInfo) => void;
299
+ }
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // Internal state machines
303
+ // ---------------------------------------------------------------------------
304
+
305
+ enum SessionState {
306
+ READY = 'ready',
307
+ CLOSED = 'closed',
308
+ }
309
+
310
+ enum RunState {
311
+ INITIALIZED = 'initialized',
312
+ STARTED = 'started',
313
+ ENDED = 'ended',
314
+ }
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // Implementation
318
+ // ---------------------------------------------------------------------------
319
+
320
+ // Spec: AIT-ST1
321
+ class DefaultAgentSession<
322
+ TInput extends CodecInputEvent,
323
+ TOutput extends CodecOutputEvent,
324
+ TProjection,
325
+ TMessage,
326
+ > implements AgentSession<TOutput, TProjection, TMessage> {
327
+ private readonly _channel: Ably.RealtimeChannel;
328
+ private readonly _codec: AgentSessionOptions<TInput, TOutput, TProjection, TMessage>['codec'];
329
+ private readonly _logger: Logger | undefined;
330
+ private readonly _onError: ((error: Ably.ErrorInfo) => void) | undefined;
331
+ private readonly _runManager: RunManager;
332
+ private readonly _registeredRuns = new Map<string, RegisteredRun>();
333
+ /**
334
+ * Reverse index from a run's triggering input codec-message-id to its
335
+ * run-id, populated once `Run.start()`'s input-event lookup resolves the
336
+ * triggering input. Lets `_handleCancelMessage` route a cancel keyed by the
337
+ * input codec-message-id (a fresh send whose run-id the client doesn't know)
338
+ * to the registered run. Entries are removed when the run ends / suspends /
339
+ * the session closes, alongside `_registeredRuns`.
340
+ */
341
+ private readonly _runIdByInputCodecMessageId = new Map<string, string>();
342
+ /**
343
+ * Cancels buffered by triggering input codec-message-id when they arrived
344
+ * before the run was known — i.e. before `Run.start()`'s input-event lookup
345
+ * resolved that input to a run. A fresh run has no run-id at the client's
346
+ * send time (the agent mints it at run-start), so an early cancel can only be
347
+ * keyed by the input codec-message-id, and the `inputCodecMessageId → run`
348
+ * linkage doesn't exist until the lookup completes. `Run.start()` consults
349
+ * this buffer as a PULL once it resolves its `resolvedInputCodecMessageId`,
350
+ * honouring any cancel that arrived first. Mirrors `_inputEventBuffer`: FIFO
351
+ * eviction at `_inputEventBufferLimit` entries, cleared on `close()`.
352
+ */
353
+ private readonly _deferredCancels = new Map<string, Ably.InboundMessage>();
354
+ /**
355
+ * Active input-event lookups keyed by `event-id`. The channel listener
356
+ * dispatches each input event to the lookup that registered for its
357
+ * `event-id`, so that messages replayed via channel rewind (and live
358
+ * messages alike) reach the right lookup without each lookup having to
359
+ * subscribe separately, and without depending on a client-minted
360
+ * `invocation-id`.
361
+ */
362
+ private readonly _pendingInputEventLookups = new Map<string, (msg: Ably.InboundMessage) => void>();
363
+ /**
364
+ * Input events buffered by `event-id` when no lookup callback was
365
+ * registered at delivery time. Rewind replays user messages on attach —
366
+ * before `run.start()` runs — so without buffering they would be dropped.
367
+ * Each `event-id` maps to an ordered array so rewind redelivery of the
368
+ * same event before registration is preserved (the lookup later dedupes by
369
+ * serial). `_registerInputEventListener` drains the buffer on registration.
370
+ * FIFO eviction at `_inputEventBufferLimit` event entries (each entry counts
371
+ * once regardless of array length).
372
+ */
373
+ private readonly _inputEventBuffer = new Map<string, Ably.InboundMessage[]>();
374
+ private readonly _inputEventBufferLimit: number;
375
+ private readonly _channelListener: (msg: Ably.InboundMessage) => void;
376
+ private readonly _inputEventLookupTimeoutMs: number;
377
+
378
+ private _state = SessionState.READY;
379
+ private _connectPromise: Promise<void> | undefined;
380
+ private _hasAttachedOnce: boolean;
381
+ private readonly _onChannelStateChange: Ably.channelEventCallback;
382
+
383
+ constructor(options: AgentSessionOptions<TInput, TOutput, TProjection, TMessage>) {
384
+ this._codec = options.codec;
385
+ // Spec: AIT-ST1a, AIT-ST1a2 — register this SDK on both the connection
386
+ // (options.agents) and channel-attach (params.agent) paths. Idempotent
387
+ // across sessions sharing one client.
388
+ const registerOptions = registerAgent(options.client, options.codec);
389
+ // Attach with a rewind window (default 2m) so a freshly-constructed
390
+ // agent session can locate an input event that was published before it
391
+ // attached (closes the lookup race when a per-request agent is spun
392
+ // up after the client has already POSTed). Tunable via
393
+ // `AgentSessionOptions.rewindWindow`.
394
+ const channelOptions: Ably.ChannelOptions = {
395
+ params: { ...registerOptions.params, rewind: options.rewindWindow ?? '2m' },
396
+ };
397
+ this._channel = options.client.channels.get(options.channelName, channelOptions);
398
+ this._logger = options.logger?.withContext({ component: 'AgentSession' });
399
+ this._onError = options.onError;
400
+ this._runManager = createRunManager(this._channel, this._logger);
401
+ this._inputEventLookupTimeoutMs = options.inputEventLookupTimeoutMs ?? 30000;
402
+ this._inputEventBufferLimit = options.inputEventBufferLimit ?? 200;
403
+
404
+ this._channelListener = (msg: Ably.InboundMessage) => {
405
+ this._handleChannelMessage(msg);
406
+ };
407
+
408
+ // Spec: AIT-ST12, AIT-ST12a
409
+ // Listen for channel state changes that break message continuity. The
410
+ // session only consumes cancel messages from the channel, so losing one
411
+ // is survivable — but the developer needs to know so they can decide
412
+ // whether to cancel in-flight work. _hasAttachedOnce is seeded from the
413
+ // channel's current state so pre-attached channels are handled correctly;
414
+ // it distinguishes the initial attach from a genuine discontinuity.
415
+ this._hasAttachedOnce = this._channel.state === 'attached';
416
+ this._onChannelStateChange = (stateChange: Ably.ChannelStateChange) => {
417
+ this._handleChannelStateChange(stateChange);
418
+ };
419
+ this._channel.on(this._onChannelStateChange);
420
+
421
+ this._logger?.debug('DefaultAgentSession(); session created');
422
+ }
423
+
424
+ // -------------------------------------------------------------------------
425
+ // Public API
426
+ // -------------------------------------------------------------------------
427
+
428
+ // Spec: AIT-ST2
429
+ // eslint-disable-next-line @typescript-eslint/promise-function-async -- preserve reference equality across calls
430
+ connect(): Promise<void> {
431
+ if (this._state === SessionState.CLOSED) {
432
+ return Promise.reject(new Ably.ErrorInfo('unable to connect; session is closed', ErrorCode.SessionClosed, 400));
433
+ }
434
+ if (this._connectPromise) return this._connectPromise;
435
+
436
+ this._logger?.trace('DefaultAgentSession.connect();');
437
+ // Subscribe unfiltered (before attach, per RTL7g — subscribe implicitly
438
+ // attaches the channel). An unfiltered subscribe ensures that messages
439
+ // replayed via channel rewind reach the dispatcher so input-event
440
+ // lookups can match against them; the dispatcher then routes by name
441
+ // (cancel vs. input event). A name-filtered subscribe would silently
442
+ // drop replayed user messages because rewind delivers them to listeners
443
+ // registered at attach time only.
444
+ this._connectPromise = this._channel.subscribe(this._channelListener).then(
445
+ () => {
446
+ this._logger?.debug('DefaultAgentSession.connect(); subscribed and attached');
447
+ },
448
+ (error: unknown) => {
449
+ const errInfo = new Ably.ErrorInfo(
450
+ `unable to subscribe to channel; ${error instanceof Error ? error.message : String(error)}`,
451
+ ErrorCode.SessionSubscriptionError,
452
+ 500,
453
+ error instanceof Ably.ErrorInfo ? error : undefined,
454
+ );
455
+ this._logger?.error('DefaultAgentSession.connect(); subscribe failed');
456
+ this._onError?.(errInfo);
457
+ throw errInfo;
458
+ },
459
+ );
460
+ return this._connectPromise;
461
+ }
462
+
463
+ /**
464
+ * Register a callback to receive the input events carrying any of the
465
+ * given `eventIds`. Lookups must share the session's unfiltered
466
+ * subscription rather than registering their own subscribe — Ably's
467
+ * rewind only delivers to listeners present at attach time.
468
+ *
469
+ * The listener remains registered after the initial buffer drain so a
470
+ * matching event that arrives live (rather than from the buffer) still
471
+ * reaches the lookup until it unregisters itself. Today the only caller
472
+ * registers a single trigger event-id; the array form keeps the
473
+ * registration capable of awaiting several ids without changing callers.
474
+ * @param eventIds - The `event-id`s this listener cares about.
475
+ * @param callback - Invoked once per matching Ably message, in buffer-insertion order for drained entries.
476
+ * @returns Unregister function. Safe to call multiple times.
477
+ */
478
+ private _registerInputEventListener(
479
+ eventIds: readonly string[],
480
+ callback: (msg: Ably.InboundMessage) => void,
481
+ ): () => void {
482
+ for (const eventId of eventIds) {
483
+ this._pendingInputEventLookups.set(eventId, callback);
484
+ }
485
+ // Drain any buffered input events for these event-ids — rewind replays
486
+ // user messages on attach before run.start() can register the callback.
487
+ // Without this drain, the lookup waits the full
488
+ // `inputEventLookupTimeoutMs` for a live arrival that never comes. Set
489
+ // all listeners before draining so a drain that completes the lookup
490
+ // synchronously cannot leave a later event-id unmapped.
491
+ for (const eventId of eventIds) {
492
+ const buffered = this._inputEventBuffer.get(eventId);
493
+ if (buffered) {
494
+ this._inputEventBuffer.delete(eventId);
495
+ for (const m of buffered) callback(m);
496
+ }
497
+ }
498
+ return () => {
499
+ for (const eventId of eventIds) {
500
+ if (this._pendingInputEventLookups.get(eventId) === callback) {
501
+ this._pendingInputEventLookups.delete(eventId);
502
+ }
503
+ }
504
+ };
505
+ }
506
+
507
+ // Spec: AIT-ST3
508
+ createRun(invocation: Invocation, runtime?: RunRuntime<TOutput>): Run<TOutput, TProjection, TMessage> {
509
+ this._logger?.trace('DefaultAgentSession.createRun();', { inputEventId: invocation.inputEventId });
510
+ return this._createRun(invocation, runtime ?? {});
511
+ }
512
+
513
+ // Spec: AIT-ST11
514
+ async close(): Promise<void> {
515
+ if (this._state === SessionState.CLOSED) return;
516
+ this._state = SessionState.CLOSED;
517
+ this._logger?.trace('DefaultAgentSession.close();');
518
+ if (this._connectPromise) {
519
+ this._channel.unsubscribe(this._channelListener);
520
+ }
521
+ this._channel.off(this._onChannelStateChange);
522
+ for (const reg of this._registeredRuns.values()) {
523
+ reg.controller.abort();
524
+ }
525
+ this._registeredRuns.clear();
526
+ this._runIdByInputCodecMessageId.clear();
527
+ this._deferredCancels.clear();
528
+ this._pendingInputEventLookups.clear();
529
+ this._inputEventBuffer.clear();
530
+ this._runManager.close();
531
+
532
+ // Detach the channel this session attached. connect() subscribes (which
533
+ // implicitly attaches), so we only detach when connect() ran. Best-effort:
534
+ // a detach failure (e.g. the channel is already FAILED) must not throw out
535
+ // of close().
536
+ if (this._connectPromise) {
537
+ try {
538
+ await this._channel.detach();
539
+ } catch (error) {
540
+ // Swallowed (see above): a detach failure must not throw out of
541
+ // close(). Logged at debug for observability.
542
+ this._logger?.debug('DefaultAgentSession.close(); channel detach failed', { error });
543
+ }
544
+ }
545
+
546
+ this._logger?.debug('DefaultAgentSession.close(); session closed');
547
+ }
548
+
549
+ // -------------------------------------------------------------------------
550
+ // Cancel message routing
551
+ // -------------------------------------------------------------------------
552
+
553
+ private async _handleCancelMessage(msg: Ably.InboundMessage): Promise<void> {
554
+ const headers = getTransportHeaders(msg);
555
+ const runId = headers[HEADER_RUN_ID];
556
+ const inputCodecMessageId = headers[HEADER_INPUT_CODEC_MESSAGE_ID];
557
+
558
+ // Malformed cancel: drop with warn. A cancel must identify its target by
559
+ // `run-id` (a continuation, whose run-id the client knows) and/or by
560
+ // `input-codec-message-id` (a fresh send, before the agent minted the
561
+ // run-id). Neither present means there is nothing to route to.
562
+ if (!runId && !inputCodecMessageId) {
563
+ this._logger?.warn('DefaultAgentSession._handleCancelMessage(); missing run-id and input-codec-message-id', {
564
+ serial: msg.serial,
565
+ });
566
+ return;
567
+ }
568
+
569
+ // Primary path — match by run-id (continuations, whose run-id the client
570
+ // already knows). Resolve the input-codec-message-id to a run-id when the
571
+ // run-id wasn't supplied (a fresh-send cancel that arrived after the run's
572
+ // input-event lookup resolved, so the linkage already exists).
573
+ const resolvedRunId =
574
+ runId ?? (inputCodecMessageId ? this._runIdByInputCodecMessageId.get(inputCodecMessageId) : undefined);
575
+ const reg = resolvedRunId ? this._registeredRuns.get(resolvedRunId) : undefined;
576
+
577
+ if (!reg) {
578
+ // The run isn't known yet. A fresh-send cancel can race ahead of the
579
+ // run's input-event lookup (which is what establishes the
580
+ // input-codec-message-id → run linkage). Buffer it by
581
+ // input-codec-message-id so `Run.start()` can pull and honour it once it
582
+ // resolves the triggering input. A bare run-id cancel for an unknown run
583
+ // is a no-op (the run never existed here, or already ended).
584
+ if (inputCodecMessageId !== undefined) {
585
+ this._bufferDeferredCancel(inputCodecMessageId, msg);
586
+ }
587
+ return;
588
+ }
589
+
590
+ await this._cancelRegistration(reg, msg);
591
+ }
592
+
593
+ /**
594
+ * Buffer a cancel that arrived before its target run was known, keyed by the
595
+ * triggering input's codec-message-id. FIFO-evicts the oldest entry at
596
+ * `_inputEventBufferLimit` (mirroring `_inputEventBuffer`). A later cancel
597
+ * for the same input replaces the earlier one — the intent is identical.
598
+ * @param inputCodecMessageId - The triggering input's codec-message-id.
599
+ * @param msg - The raw cancel message (passed to `onCancel`).
600
+ */
601
+ private _bufferDeferredCancel(inputCodecMessageId: string, msg: Ably.InboundMessage): void {
602
+ const evicted = evictOldestIfFull(this._deferredCancels, inputCodecMessageId, this._inputEventBufferLimit);
603
+ if (evicted !== undefined) {
604
+ this._logger?.warn('DefaultAgentSession._bufferDeferredCancel(); deferred-cancel buffer full, dropping oldest', {
605
+ evictedInputCodecMessageId: evicted,
606
+ limit: this._inputEventBufferLimit,
607
+ });
608
+ }
609
+ this._deferredCancels.set(inputCodecMessageId, msg);
610
+ this._logger?.debug('DefaultAgentSession._bufferDeferredCancel(); buffered early cancel', {
611
+ inputCodecMessageId,
612
+ serial: msg.serial,
613
+ });
614
+ }
615
+
616
+ /**
617
+ * Pull and honour a cancel buffered before this run was known. Called from
618
+ * `Run.start()` once the input-event lookup resolves the run's triggering
619
+ * input codec-message-id — the point at which the
620
+ * `input-codec-message-id → run` linkage first exists. No-op when no cancel
621
+ * was buffered for that input.
622
+ * @param reg - The now-known run registration.
623
+ * @param inputCodecMessageId - The run's resolved triggering input codec-message-id.
624
+ */
625
+ private async _pullDeferredCancel(reg: RegisteredRun, inputCodecMessageId: string): Promise<void> {
626
+ const buffered = this._deferredCancels.get(inputCodecMessageId);
627
+ if (buffered === undefined) return;
628
+ this._deferredCancels.delete(inputCodecMessageId);
629
+ this._logger?.debug('DefaultAgentSession._pullDeferredCancel(); honouring buffered cancel', {
630
+ runId: reg.runId,
631
+ inputCodecMessageId,
632
+ });
633
+ await this._cancelRegistration(reg, buffered);
634
+ }
635
+
636
+ /**
637
+ * Fire a cancel against a known run: consult its `onCancel` authorization
638
+ * hook (if any), then abort the run's controller. Shared by the run-id match,
639
+ * the input-codec-message-id match, and the buffered-cancel pull so all three
640
+ * honour `onCancel` and surface handler errors identically.
641
+ * @param reg - The target run registration.
642
+ * @param msg - The raw cancel message (passed to `onCancel`).
643
+ */
644
+ private async _cancelRegistration(reg: RegisteredRun, msg: Ably.InboundMessage): Promise<void> {
645
+ const { runId } = reg;
646
+ this._logger?.debug('DefaultAgentSession._cancelRegistration(); matched run', { runId });
647
+
648
+ const request: CancelRequest = { message: msg, runId };
649
+
650
+ try {
651
+ if (reg.onCancel) {
652
+ const allowed = await reg.onCancel(request);
653
+ if (!allowed) {
654
+ this._logger?.debug('DefaultAgentSession._cancelRegistration(); cancel rejected by onCancel', {
655
+ runId,
656
+ });
657
+ return;
658
+ }
659
+ }
660
+ reg.controller.abort();
661
+ this._logger?.debug('DefaultAgentSession._cancelRegistration(); run cancelled', { runId });
662
+ } catch (error) {
663
+ const errInfo = new Ably.ErrorInfo(
664
+ `unable to process cancel for run ${runId}; onCancel handler threw: ${error instanceof Error ? error.message : String(error)}`,
665
+ ErrorCode.CancelListenerError,
666
+ 500,
667
+ error instanceof Ably.ErrorInfo ? error : undefined,
668
+ );
669
+ this._logger?.error('DefaultAgentSession._cancelRegistration(); onCancel threw', { runId });
670
+ (reg.onError ?? this._onError)?.(errInfo);
671
+ }
672
+ }
673
+
674
+ // -------------------------------------------------------------------------
675
+ // Channel state change handler
676
+ // -------------------------------------------------------------------------
677
+
678
+ // Spec: AIT-ST12, AIT-ST12a
679
+ private _handleChannelStateChange(stateChange: Ably.ChannelStateChange): void {
680
+ if (this._state === SessionState.CLOSED) return;
681
+
682
+ const { current, resumed } = stateChange;
683
+
684
+ // Track the initial attach so we don't treat it as a discontinuity
685
+ if (current === 'attached' && !this._hasAttachedOnce) {
686
+ this._hasAttachedOnce = true;
687
+ return;
688
+ }
689
+
690
+ // Continuity-breaking states:
691
+ // - FAILED, SUSPENDED, DETACHED: no more messages expected (or gap)
692
+ // - ATTACHED with resumed: false (UPDATE): messages were lost
693
+ const continuityLost =
694
+ current === 'failed' || current === 'suspended' || current === 'detached' || (current === 'attached' && !resumed);
695
+
696
+ if (!continuityLost) return;
697
+
698
+ this._logger?.error('DefaultAgentSession._handleChannelStateChange(); channel continuity lost', {
699
+ current,
700
+ resumed,
701
+ previous: stateChange.previous,
702
+ });
703
+
704
+ const err = new Ably.ErrorInfo(
705
+ `unable to deliver cancel messages; channel continuity lost (${current}${current === 'attached' ? ', resumed: false' : ''})`,
706
+ ErrorCode.ChannelContinuityLost,
707
+ 500,
708
+ stateChange.reason,
709
+ );
710
+
711
+ // Session-level notification only: continuity loss is not scoped to any
712
+ // run. Per-run onError handlers are reserved for errors from that run's
713
+ // own operations (publish failures, encoder errors). Developers that need
714
+ // per-run reaction can iterate active runs from the session handler.
715
+ this._onError?.(err);
716
+ }
717
+
718
+ // -------------------------------------------------------------------------
719
+ // Channel subscription handler
720
+ // -------------------------------------------------------------------------
721
+
722
+ private _handleChannelMessage(msg: Ably.InboundMessage): void {
723
+ try {
724
+ if (msg.name === EVENT_CANCEL) {
725
+ // Fire-and-forget async handler — errors are caught internally.
726
+ this._handleCancelMessage(msg).catch((error: unknown) => {
727
+ const errInfo = new Ably.ErrorInfo(
728
+ `unable to route cancel message; ${error instanceof Error ? error.message : String(error)}`,
729
+ ErrorCode.CancelListenerError,
730
+ 500,
731
+ error instanceof Ably.ErrorInfo ? error : undefined,
732
+ );
733
+ this._logger?.error('DefaultAgentSession._handleChannelMessage(); cancel routing error');
734
+ this._onError?.(errInfo);
735
+ });
736
+ return;
737
+ }
738
+
739
+ // Dispatch client-published input events to the lookup registered
740
+ // for their `event-id`. Every client-originated event in an
741
+ // invocation (user-message AND amend events such as tool-approval
742
+ // responses and client tool outputs) carries `event-id`; the lookup
743
+ // waits for every promised id to arrive before letting the run start
744
+ // LLM work. Routing by `event-id` rather than `invocation-id` keeps
745
+ // the dispatcher independent of any client-minted invocation
746
+ // identity. Server-side lifecycle messages (run-start, run-end,
747
+ // cancel, error) never stamp `event-id`, so they're naturally
748
+ // excluded.
749
+ const headers = getTransportHeaders(msg);
750
+ const eventId = headers[HEADER_EVENT_ID];
751
+ if (eventId !== undefined) {
752
+ const listener = this._pendingInputEventLookups.get(eventId);
753
+ if (listener) {
754
+ listener(msg);
755
+ } else {
756
+ // Buffer for a future `_registerInputEventListener` call. This is
757
+ // load-bearing for the "agent attaches after publish" scenario
758
+ // where channel rewind delivers user messages before
759
+ // `run.start()` runs.
760
+ const existing = this._inputEventBuffer.get(eventId);
761
+ if (existing) {
762
+ existing.push(msg);
763
+ } else {
764
+ // FIFO eviction: drop the oldest event entry (and all its buffered
765
+ // redeliveries). Clients whose input event was evicted will fail
766
+ // their lookup with `InputEventNotFound` — this warn is the only
767
+ // operator-visible signal that capacity caused the failure.
768
+ const evicted = evictOldestIfFull(this._inputEventBuffer, eventId, this._inputEventBufferLimit);
769
+ if (evicted !== undefined) {
770
+ this._logger?.warn(
771
+ 'DefaultAgentSession._handleChannelMessage(); input-event buffer full, dropping oldest entry',
772
+ { evictedEventId: evicted, limit: this._inputEventBufferLimit },
773
+ );
774
+ }
775
+ this._inputEventBuffer.set(eventId, [msg]);
776
+ }
777
+ }
778
+ }
779
+ } catch (error) {
780
+ const errInfo = new Ably.ErrorInfo(
781
+ `unable to process channel message; ${error instanceof Error ? error.message : String(error)}`,
782
+ ErrorCode.SessionSubscriptionError,
783
+ 500,
784
+ error instanceof Ably.ErrorInfo ? error : undefined,
785
+ );
786
+ this._logger?.error('DefaultAgentSession._handleChannelMessage(); subscription error');
787
+ this._onError?.(errInfo);
788
+ }
789
+ }
790
+
791
+ // -------------------------------------------------------------------------
792
+ // Connection guard
793
+ // -------------------------------------------------------------------------
794
+
795
+ private async _requireConnected(method: string): Promise<void> {
796
+ if (!this._connectPromise) {
797
+ throw new Ably.ErrorInfo(
798
+ `unable to ${method}; connect() must be called before ${method}()`,
799
+ ErrorCode.InvalidArgument,
800
+ 400,
801
+ );
802
+ }
803
+ return this._connectPromise;
804
+ }
805
+
806
+ // -------------------------------------------------------------------------
807
+ // Run creation
808
+ // -------------------------------------------------------------------------
809
+
810
+ private _createRun(invocation: Invocation, runtime: RunRuntime<TOutput>): Run<TOutput, TProjection, TMessage> {
811
+ // The run-id is not carried in the invocation body — the agent mints it.
812
+ // Mint a provisional id now (or take the `runtime.runId` override for
813
+ // tests / in-process drivers) — this IS the id for a fresh run. A
814
+ // continuation overrides it in `Run.start()` with the existing run-id read
815
+ // off the triggering input event's wire headers (the run it re-enters).
816
+ // Mirrors the invocationId mint below.
817
+ let runId = runtime.runId ?? crypto.randomUUID();
818
+ // The agent mints the invocation id — one per HTTP request that invokes
819
+ // it. A per-run override (runtime.invocationId) supports deterministic ids
820
+ // in tests and in-process drivers.
821
+ const invocationId = runtime.invocationId ?? crypto.randomUUID();
822
+ const inputEventLookupTimeoutMs = this._inputEventLookupTimeoutMs;
823
+ const { onMessage, onCancelled, onCancel, onError: runOnError, signal: externalSignal } = runtime;
824
+
825
+ const controller = new AbortController();
826
+ let state = RunState.INITIALIZED;
827
+
828
+ // Compose the internal controller signal with the external signal (e.g.
829
+ // req.signal) so platform-level cancellation (request cancellation, function
830
+ // timeout) cancels the run through the same path as Ably cancel messages.
831
+ const signal = externalSignal ? AbortSignal.any([controller.signal, externalSignal]) : controller.signal;
832
+
833
+ // Spec: AIT-ST3a — register immediately so `close()` aborts an in-flight
834
+ // start() and a post-lookup cancel can fire the AbortSignal. Keyed by the
835
+ // provisional run-id; a continuation re-keys to the real id in start()
836
+ // once the triggering input reveals it.
837
+ const registration: RegisteredRun = {
838
+ runId,
839
+ invocationId,
840
+ controller,
841
+ signal,
842
+ onCancel,
843
+ onError: runOnError,
844
+ };
845
+ this._registeredRuns.set(runId, registration);
846
+
847
+ // Capture instance members as locals so arrow functions close over them
848
+ // without needing `this` (avoids unicorn/no-this-assignment).
849
+ const logger = this._logger;
850
+ const runManager = this._runManager;
851
+ const codec = this._codec;
852
+ const channel = this._channel;
853
+ const registeredRuns = this._registeredRuns;
854
+ const runIdByInputCodecMessageId = this._runIdByInputCodecMessageId;
855
+ const deferredCancels = this._deferredCancels;
856
+ const requireConnected = this._requireConnected.bind(this);
857
+ const registerInputEventListener = this._registerInputEventListener.bind(this);
858
+ const pullDeferredCancel = this._pullDeferredCancel.bind(this);
859
+ const inputEventId = invocation.inputEventId;
860
+
861
+ // `viewMessages` starts empty. `Run.start()` populates it via the
862
+ // channel-rewind input-event lookup, pulling in user-message MessageNodes
863
+ // as they arrive on the channel.
864
+ const viewMessages: MessageNode<TMessage>[] = [];
865
+ const view: RunView<TMessage> = {
866
+ get messages() {
867
+ return viewMessages;
868
+ },
869
+ };
870
+
871
+ // Per-run metadata resolved from the input-event lookup result. The first
872
+ // matched wire message's headers carry the run's `clientId`, `parent`, and
873
+ // `forkOf`, and — for a continuation — the `run-id` it re-enters (a fresh
874
+ // input carries none; the client stamps a run-id only when re-entering a
875
+ // run it already knows). Its Ably-level publisher `clientId` becomes the
876
+ // `inputClientId` re-stamped on the agent's own publishes. Captured
877
+ // separately from `viewMessages` because tool-resolution wire messages
878
+ // (`tool-output-available` etc.) decode to chunks and produce zero
879
+ // MessageNodes — the metadata still needs to surface.
880
+ let resolvedClientId: string | undefined;
881
+ let resolvedInputClientId: string | undefined;
882
+ let resolvedParent: string | undefined;
883
+ let resolvedForkOf: string | undefined;
884
+ let resolvedRegenerates: string | undefined;
885
+ let resolvedInputCodecMessageId: string | undefined;
886
+ let resolvedContinuation = false;
887
+ let firstLookupHeaders: Record<string, string> | undefined;
888
+ /**
889
+ * The reply run's structural-parent fallback, computed once in
890
+ * `Run.start()` (after the input-event lookup has populated `viewMessages`)
891
+ * and consumed by every `Run.pipe()` publish. A per-stream
892
+ * `streamOpts.parent` still overrides it. Storing it here keeps it stable
893
+ * across pipes and decouples the assistant's structural parent from the
894
+ * run-start wire's own `parent`.
895
+ */
896
+ let assistantParentFallback: string | undefined;
897
+ /**
898
+ * Raw Ably messages observed live by the input-event lookup. Passed to
899
+ * `loadRunProjection` so the just-published client wires don't need
900
+ * to wait on Ably's channel history indexing window. Empty when no
901
+ * lookup ran or no messages matched.
902
+ */
903
+ let liveLookupMessages: readonly Ably.InboundMessage[] | undefined;
904
+
905
+ /**
906
+ * Remove this run from the session's routing maps. Drops the
907
+ * `_registeredRuns` entry plus the `input-codec-message-id → run-id`
908
+ * reverse index (and any stale deferred cancel still buffered for that
909
+ * input), keeping the cancel-routing state consistent when the run ends,
910
+ * suspends, or its start fails.
911
+ */
912
+ const deregisterRun = (): void => {
913
+ registeredRuns.delete(runId);
914
+ if (resolvedInputCodecMessageId !== undefined) {
915
+ runIdByInputCodecMessageId.delete(resolvedInputCodecMessageId);
916
+ deferredCancels.delete(resolvedInputCodecMessageId);
917
+ }
918
+ };
919
+
920
+ // Most recently loaded projection for this run only, cached by
921
+ // `Run.loadProjection()` and `Run.loadConversation()` so the `messages`
922
+ // getter can return the run's folded messages. `undefined` before any
923
+ // load call; the getter then falls back to the live `viewMessages`.
924
+ let cachedProjection: TProjection | undefined;
925
+
926
+ // Full multi-turn conversation, set by `Run.loadConversation()`. When set,
927
+ // it takes priority over `cachedProjection` in the `messages` getter —
928
+ // the getter then returns the complete ancestor-chain + current-run
929
+ // messages instead of the current run alone.
930
+ let cachedConversation: TMessage[] | undefined;
931
+
932
+ const run: Run<TOutput, TProjection, TMessage> = {
933
+ get runId() {
934
+ return runId;
935
+ },
936
+ get invocationId() {
937
+ return invocationId;
938
+ },
939
+ get abortSignal() {
940
+ return signal;
941
+ },
942
+ get view() {
943
+ return view;
944
+ },
945
+ get messages() {
946
+ if (cachedConversation !== undefined) {
947
+ return [...cachedConversation];
948
+ }
949
+ if (cachedProjection !== undefined) {
950
+ return codec.getMessages(cachedProjection).map((m) => m.message);
951
+ }
952
+ return viewMessages.map((n) => n.message);
953
+ },
954
+
955
+ // Spec: AIT-ST4, AIT-ST4a, AIT-ST4b
956
+ start: async (): Promise<void> => {
957
+ logger?.trace('Run.start();', { runId, inputEventId });
958
+
959
+ await requireConnected('start');
960
+
961
+ // Spec: AIT-ST4a
962
+ if (signal.aborted) {
963
+ throw new Ably.ErrorInfo(
964
+ `unable to start run; run ${runId} was cancelled before start()`,
965
+ ErrorCode.InvalidArgument,
966
+ 400,
967
+ );
968
+ }
969
+ if (state !== RunState.INITIALIZED) return;
970
+ state = RunState.STARTED;
971
+
972
+ // Look up the triggering input event on the channel so the agent
973
+ // can read the user's message and per-run metadata (parent, forkOf,
974
+ // continuation flag) before publishing run-start. Skip when
975
+ // inputEventLookupTimeoutMs === 0 (tests and in-process drivers) or
976
+ // when no inputEventId is set (invocation requires no channel lookup).
977
+ if (inputEventId && inputEventLookupTimeoutMs > 0) {
978
+ try {
979
+ const found = await lookupInputEvents<TInput, TOutput, TProjection, TMessage>({
980
+ register: (callback) => registerInputEventListener([inputEventId], callback),
981
+ codec,
982
+ invocationId,
983
+ runId,
984
+ expectedInputEventIds: [inputEventId],
985
+ timeoutMs: inputEventLookupTimeoutMs,
986
+ signal,
987
+ logger,
988
+ });
989
+ for (const m of found.nodes) viewMessages.push(m);
990
+ if (found.firstHeaders !== undefined) firstLookupHeaders = found.firstHeaders;
991
+ if (found.firstClientId !== undefined) resolvedInputClientId = found.firstClientId;
992
+ liveLookupMessages = found.rawMessages;
993
+ } catch (error) {
994
+ const errInfo =
995
+ error instanceof Ably.ErrorInfo
996
+ ? error
997
+ : new Ably.ErrorInfo(
998
+ `unable to look up input event; ${error instanceof Error ? error.message : String(error)}`,
999
+ ErrorCode.InputEventNotFound,
1000
+ 504,
1001
+ );
1002
+ // The rejection bubbles up to the developer's HTTP handler,
1003
+ // which surfaces the failure as a non-2xx response — that is
1004
+ // the signal the client sees. No channel publish: an
1005
+ // `ai-run-end` without a preceding `ai-run-start` would break
1006
+ // the lifecycle invariant for other channel observers.
1007
+ deregisterRun();
1008
+ logger?.error('Run.start(); input-event lookup failed', { runId, invocationId });
1009
+ throw errInfo;
1010
+ }
1011
+ }
1012
+
1013
+ // Resolve per-run metadata from the first matched wire message's
1014
+ // headers — they carry `clientId`, `parent`, and `forkOf`.
1015
+ // Continuations of a suspended run pick up the suspended assistant's
1016
+ // parent in the same headers (the continuation wire message parents off
1017
+ // the assistant). A `run-id` on the triggering input marks a
1018
+ // continuation (re-entry via `ai-run-resume`); a fresh input carries
1019
+ // none and opens the run with `ai-run-start`. Fall back to the first
1020
+ // MessageNode's headers for the path where the lookup ran with
1021
+ // `viewMessages` already populated and no `firstHeaders` was captured.
1022
+ const sourceHeaders = firstLookupHeaders ?? viewMessages[0]?.headers;
1023
+ if (sourceHeaders) {
1024
+ resolvedClientId = sourceHeaders[HEADER_RUN_CLIENT_ID];
1025
+ resolvedParent = sourceHeaders[HEADER_PARENT];
1026
+ resolvedForkOf = sourceHeaders[HEADER_FORK_OF];
1027
+ resolvedRegenerates = sourceHeaders[HEADER_MSG_REGENERATE];
1028
+ resolvedInputCodecMessageId = sourceHeaders[HEADER_CODEC_MESSAGE_ID];
1029
+
1030
+ // The triggering input's run-id (if any) IS this run's identity.
1031
+ // Present → a continuation re-entering that run: adopt the id,
1032
+ // overriding the provisional one minted at construction, and re-key
1033
+ // the registration so cancel routing / deregistration resolve to the
1034
+ // real run. Absent → a fresh run: the provisional id stands and the
1035
+ // run opens with run-start.
1036
+ const wireRunId = sourceHeaders[HEADER_RUN_ID];
1037
+ resolvedContinuation = wireRunId !== undefined;
1038
+ if (wireRunId !== undefined && wireRunId !== runId) {
1039
+ registeredRuns.delete(runId);
1040
+ runId = wireRunId;
1041
+ registration.runId = runId;
1042
+ registeredRuns.set(runId, registration);
1043
+ }
1044
+ }
1045
+
1046
+ // Compute the reply run's structural-parent fallback now that the
1047
+ // lookup has populated `viewMessages`: the triggering user message,
1048
+ // or — for regenerate wires that match by inputEventId but produce no
1049
+ // MessageNodes — the input wire's own `parent`. `Run.pipe()` consumes
1050
+ // this for every assistant publish.
1051
+ assistantParentFallback = viewMessages.at(-1)?.codecMessageId ?? resolvedParent;
1052
+
1053
+ // The triggering input's codec-message-id is now resolved, so the
1054
+ // `input-codec-message-id → run` linkage exists: index it for live
1055
+ // cancels and pull any cancel that arrived before the run was known
1056
+ // (a fresh-send cancel published before the agent minted this run-id).
1057
+ // Honouring it here may abort the controller before run-start; that is
1058
+ // fine — the abort propagates through the same signal a normal cancel
1059
+ // would use.
1060
+ if (resolvedInputCodecMessageId !== undefined) {
1061
+ runIdByInputCodecMessageId.set(resolvedInputCodecMessageId, runId);
1062
+ await pullDeferredCancel(registration, resolvedInputCodecMessageId);
1063
+ }
1064
+
1065
+ try {
1066
+ await runManager.startRun(runId, resolvedClientId, controller, {
1067
+ // Stamp the reply run's STRUCTURAL parent (its input node, M_user) —
1068
+ // the same value the output path stamps — not the input wire's own
1069
+ // parent. Makes `parent` structural on every wire so the Tree's two
1070
+ // creation paths agree regardless of arrival order. Valid only now
1071
+ // that M_user is a separate input node (the two-node flip).
1072
+ parent: assistantParentFallback,
1073
+ forkOf: resolvedForkOf,
1074
+ regenerates: resolvedRegenerates,
1075
+ invocationId,
1076
+ inputClientId: resolvedInputClientId,
1077
+ inputCodecMessageId: resolvedInputCodecMessageId,
1078
+ continuation: resolvedContinuation,
1079
+ });
1080
+ } catch (error) {
1081
+ const errInfo = new Ably.ErrorInfo(
1082
+ `unable to publish run-start for run ${runId}; ${error instanceof Error ? error.message : String(error)}`,
1083
+ ErrorCode.RunLifecycleError,
1084
+ 500,
1085
+ error instanceof Ably.ErrorInfo ? error : undefined,
1086
+ );
1087
+ logger?.error('Run.start(); failed to publish run-start', { runId });
1088
+ throw errInfo;
1089
+ }
1090
+
1091
+ logger?.debug('Run.start(); run started', { runId, inputEventId });
1092
+ },
1093
+
1094
+ // Spec: AIT-ST5c
1095
+ addEvents: async (nodes: EventsNode<TOutput>[]): Promise<void> => {
1096
+ logger?.trace('Run.addEvents();', { runId, count: nodes.length });
1097
+
1098
+ await requireConnected('addEvents');
1099
+
1100
+ if (state === RunState.INITIALIZED) {
1101
+ throw new Ably.ErrorInfo(
1102
+ `unable to add events; start() must be called before addEvents() (run ${runId})`,
1103
+ ErrorCode.InvalidArgument,
1104
+ 400,
1105
+ );
1106
+ }
1107
+
1108
+ const runOwnerClientId = runManager.getClientId(runId);
1109
+
1110
+ try {
1111
+ for (const node of nodes) {
1112
+ const headers = buildTransportHeaders({
1113
+ role: 'assistant',
1114
+ runId,
1115
+ codecMessageId: node.codecMessageId,
1116
+ runClientId: runOwnerClientId,
1117
+ invocationId,
1118
+ inputClientId: resolvedInputClientId,
1119
+ inputCodecMessageId: resolvedInputCodecMessageId,
1120
+ });
1121
+
1122
+ const encoder = codec.createEncoder(channel, {
1123
+ extras: { headers },
1124
+ onMessage,
1125
+ });
1126
+
1127
+ for (const event of node.events) {
1128
+ await encoder.publishOutput(event);
1129
+ }
1130
+
1131
+ await encoder.close();
1132
+ }
1133
+ } catch (error) {
1134
+ const errInfo = new Ably.ErrorInfo(
1135
+ `unable to publish events for run ${runId}; ${error instanceof Error ? error.message : String(error)}`,
1136
+ ErrorCode.RunLifecycleError,
1137
+ 500,
1138
+ error instanceof Ably.ErrorInfo ? error : undefined,
1139
+ );
1140
+ logger?.error('Run.addEvents(); publish failed', { runId });
1141
+ throw errInfo;
1142
+ }
1143
+
1144
+ logger?.debug('Run.addEvents(); events published', { runId, count: nodes.length });
1145
+ },
1146
+
1147
+ loadProjection: async (): Promise<TProjection> => {
1148
+ logger?.trace('Run.loadProjection();', { runId });
1149
+ await requireConnected('loadProjection');
1150
+ const projection = await loadRunProjection<TInput, TOutput, TProjection, TMessage>({
1151
+ channel,
1152
+ codec,
1153
+ runId,
1154
+ signal,
1155
+ logger,
1156
+ liveMessages: liveLookupMessages,
1157
+ });
1158
+ cachedProjection = projection;
1159
+ return projection;
1160
+ },
1161
+
1162
+ loadConversation: async (options?: LoadConversationOptions): Promise<TMessage[]> => {
1163
+ logger?.trace('Run.loadConversation();', { runId });
1164
+ await requireConnected('loadConversation');
1165
+ const { messages, projection } = await loadConversation<TInput, TOutput, TProjection, TMessage>({
1166
+ channel,
1167
+ codec,
1168
+ runId,
1169
+ signal,
1170
+ logger,
1171
+ liveMessages: liveLookupMessages,
1172
+ assistantParentFallback,
1173
+ pageLimit: options?.pageLimit ?? 200,
1174
+ maxMessages: options?.maxMessages ?? 2000,
1175
+ });
1176
+ cachedProjection = projection;
1177
+ cachedConversation = messages;
1178
+ return messages;
1179
+ },
1180
+
1181
+ // Spec: AIT-ST6, AIT-ST6a, AIT-ST6b, AIT-ST6b1, AIT-ST6b2, AIT-ST6b3, AIT-ST6c
1182
+ pipe: async (stream: ReadableStream<TOutput>, streamOpts?: PipeOptions<TOutput>): Promise<StreamResult> => {
1183
+ logger?.trace('Run.pipe();', { runId });
1184
+
1185
+ await requireConnected('pipe');
1186
+
1187
+ if (state === RunState.INITIALIZED) {
1188
+ throw new Ably.ErrorInfo(
1189
+ `unable to pipe stream; start() must be called before pipe() (run ${runId})`,
1190
+ ErrorCode.InvalidArgument,
1191
+ 400,
1192
+ );
1193
+ }
1194
+
1195
+ const runOwnerClientId = runManager.getClientId(runId);
1196
+
1197
+ // The assistant message's parent: an explicit per-stream
1198
+ // `streamOpts.parent` from the caller, else the reply run's
1199
+ // structural-parent fallback computed once at run-start
1200
+ // (`assistantParentFallback` — the triggering user message, or the
1201
+ // input wire's own parent for regenerate wires that produced no
1202
+ // MessageNodes). Owning the default here means agent routes don't have
1203
+ // to pass `{ parent: lastUserCodecMessageId }` to keep tree threading
1204
+ // correct; edit-then-regenerate sibling resolution relies on the
1205
+ // user→assistant chain being explicit.
1206
+ const assistantParent = streamOpts?.parent ?? assistantParentFallback;
1207
+ const assistantForkOf = streamOpts?.forkOf ?? resolvedForkOf;
1208
+ // Echo `msg-regenerate` on the assistant wire so that a
1209
+ // client receiving the assistant chunk before `ai-run-start`
1210
+ // (e.g. via history pagination across a page boundary, or a lost
1211
+ // lifecycle publish) can still populate `RunNode.regeneratesCodecMessageId`
1212
+ // when creating the Run from headers. Mirrors the symmetric
1213
+ // behaviour for `assistantForkOf` on edit runs.
1214
+ const assistantRegenerates = resolvedRegenerates;
1215
+
1216
+ const codecMessageId = crypto.randomUUID();
1217
+ const defaultHeaders = buildTransportHeaders({
1218
+ role: 'assistant',
1219
+ runId,
1220
+ codecMessageId,
1221
+ runClientId: runOwnerClientId,
1222
+ parent: assistantParent,
1223
+ forkOf: assistantForkOf,
1224
+ invocationId,
1225
+ inputClientId: resolvedInputClientId,
1226
+ inputCodecMessageId: resolvedInputCodecMessageId,
1227
+ regenerates: assistantRegenerates,
1228
+ });
1229
+ const encoder = codec.createEncoder(channel, {
1230
+ extras: { headers: defaultHeaders },
1231
+ onMessage,
1232
+ messageId: codecMessageId,
1233
+ });
1234
+
1235
+ const result = await pipeStream(stream, encoder, signal, onCancelled, streamOpts?.resolveWriteOptions, logger);
1236
+
1237
+ if (result.error) {
1238
+ const errInfo = new Ably.ErrorInfo(
1239
+ `unable to pipe response for run ${runId}; ${result.error.message}`,
1240
+ ErrorCode.StreamError,
1241
+ 500,
1242
+ result.error instanceof Ably.ErrorInfo ? result.error : undefined,
1243
+ );
1244
+ logger?.error('Run.pipe(); stream error', { runId });
1245
+ runOnError?.(errInfo);
1246
+ }
1247
+
1248
+ logger?.debug('Run.pipe(); stream finished', { runId, reason: result.reason });
1249
+ return result;
1250
+ },
1251
+
1252
+ suspend: async (): Promise<void> => {
1253
+ logger?.trace('Run.suspend();', { runId });
1254
+
1255
+ await requireConnected('suspend');
1256
+
1257
+ if (state === RunState.INITIALIZED) {
1258
+ throw new Ably.ErrorInfo(
1259
+ `unable to suspend run; start() must be called before suspend() (run ${runId})`,
1260
+ ErrorCode.InvalidArgument,
1261
+ 400,
1262
+ );
1263
+ }
1264
+ // ENDED is the terminal state for either an end or a suspend on this
1265
+ // Run instance; a second terminal call is a no-op.
1266
+ if (state === RunState.ENDED) return;
1267
+ state = RunState.ENDED;
1268
+
1269
+ try {
1270
+ await runManager.suspendRun(runId, invocationId, resolvedInputClientId, resolvedInputCodecMessageId);
1271
+ } catch (error) {
1272
+ const errInfo = new Ably.ErrorInfo(
1273
+ `unable to publish run-suspend for run ${runId}; ${error instanceof Error ? error.message : String(error)}`,
1274
+ ErrorCode.RunLifecycleError,
1275
+ 500,
1276
+ error instanceof Ably.ErrorInfo ? error : undefined,
1277
+ );
1278
+ logger?.error('Run.suspend(); failed to publish run-suspend', { runId });
1279
+ throw errInfo;
1280
+ } finally {
1281
+ deregisterRun();
1282
+ }
1283
+
1284
+ logger?.debug('Run.suspend(); run suspended', { runId });
1285
+ },
1286
+
1287
+ // Spec: AIT-ST7, AIT-ST7a, AIT-ST7b
1288
+ end: async (reason: RunEndReason): Promise<void> => {
1289
+ logger?.trace('Run.end();', { runId, reason });
1290
+
1291
+ await requireConnected('end');
1292
+
1293
+ if (state === RunState.INITIALIZED) {
1294
+ throw new Ably.ErrorInfo(
1295
+ `unable to end run; start() must be called before end() (run ${runId})`,
1296
+ ErrorCode.InvalidArgument,
1297
+ 400,
1298
+ );
1299
+ }
1300
+ if (state === RunState.ENDED) return;
1301
+ state = RunState.ENDED;
1302
+
1303
+ try {
1304
+ await runManager.endRun(runId, reason, invocationId, resolvedInputClientId, resolvedInputCodecMessageId);
1305
+ } catch (error) {
1306
+ const errInfo = new Ably.ErrorInfo(
1307
+ `unable to publish run-end for run ${runId}; ${error instanceof Error ? error.message : String(error)}`,
1308
+ ErrorCode.RunLifecycleError,
1309
+ 500,
1310
+ error instanceof Ably.ErrorInfo ? error : undefined,
1311
+ );
1312
+ logger?.error('Run.end(); failed to publish run-end', { runId });
1313
+ throw errInfo;
1314
+ } finally {
1315
+ deregisterRun();
1316
+ }
1317
+
1318
+ logger?.debug('Run.end(); run ended', { runId, reason });
1319
+ },
1320
+ };
1321
+
1322
+ return run;
1323
+ }
1324
+ }
1325
+
1326
+ // ---------------------------------------------------------------------------
1327
+ // Factory
1328
+ // ---------------------------------------------------------------------------
1329
+
1330
+ /**
1331
+ * Create an agent (server-side) session bound to the given Realtime client
1332
+ * and channel name. The caller owns the client's lifecycle; the session
1333
+ * owns its channel.
1334
+ * @param options - Session configuration.
1335
+ * @returns A new {@link AgentSession} instance.
1336
+ */
1337
+ export const createAgentSession = <
1338
+ TInput extends CodecInputEvent,
1339
+ TOutput extends CodecOutputEvent,
1340
+ TProjection,
1341
+ TMessage,
1342
+ >(
1343
+ options: AgentSessionOptions<TInput, TOutput, TProjection, TMessage>,
1344
+ ): AgentSession<TOutput, TProjection, TMessage> => new DefaultAgentSession(options);