@ably/ai-transport 0.0.1 → 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 (167) hide show
  1. package/README.md +114 -116
  2. package/dist/ably-ai-transport.js +1743 -961
  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 +117 -39
  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 +410 -101
  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 +97 -17
  18. package/dist/core/transport/index.d.ts +5 -3
  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 -8
  24. package/dist/core/transport/run-manager.d.ts +78 -0
  25. package/dist/core/transport/tree.d.ts +435 -0
  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 -402
  32. package/dist/core/transport/view.d.ts +354 -0
  33. package/dist/errors.d.ts +37 -9
  34. package/dist/index.d.ts +6 -6
  35. package/dist/logger.d.ts +12 -0
  36. package/dist/react/ably-ai-transport-react.js +1164 -645
  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 +16 -10
  44. package/dist/react/internal/use-resolved-session.d.ts +36 -0
  45. package/dist/react/use-ably-messages.d.ts +20 -11
  46. package/dist/react/use-client-session.d.ts +81 -0
  47. package/dist/react/use-create-view.d.ts +23 -0
  48. package/dist/react/use-tree.d.ts +35 -0
  49. package/dist/react/use-view.d.ts +110 -0
  50. package/dist/utils.d.ts +32 -23
  51. package/dist/vercel/ably-ai-transport-vercel.js +2748 -1625
  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 +50 -0
  61. package/dist/vercel/index.d.ts +4 -2
  62. package/dist/vercel/react/ably-ai-transport-vercel-react.js +10298 -1410
  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 +70 -1
  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 +33 -0
  67. package/dist/vercel/react/contexts/chat-transport-provider.d.ts +96 -0
  68. package/dist/vercel/react/index.d.ts +4 -0
  69. package/dist/vercel/react/use-chat-transport.d.ts +66 -21
  70. package/dist/vercel/react/use-message-sync.d.ts +31 -12
  71. package/dist/vercel/run-end-reason.d.ts +29 -0
  72. package/dist/vercel/transport/chat-transport.d.ts +71 -30
  73. package/dist/vercel/transport/index.d.ts +25 -18
  74. package/dist/vercel/transport/run-output-stream.d.ts +56 -0
  75. package/dist/version.d.ts +2 -0
  76. package/package.json +47 -34
  77. package/src/constants.ts +126 -47
  78. package/src/core/agent.ts +68 -0
  79. package/src/core/codec/decoder.ts +71 -98
  80. package/src/core/codec/encoder.ts +115 -58
  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 +438 -106
  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 +182 -19
  89. package/src/core/transport/index.ts +29 -22
  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 +58 -40
  95. package/src/core/transport/run-manager.ts +249 -0
  96. package/src/core/transport/tree.ts +1167 -0
  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 -527
  103. package/src/core/transport/view.ts +1271 -0
  104. package/src/errors.ts +42 -9
  105. package/src/event-emitter.ts +3 -2
  106. package/src/index.ts +55 -39
  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 +27 -10
  112. package/src/react/internal/use-resolved-session.ts +63 -0
  113. package/src/react/use-ably-messages.ts +47 -19
  114. package/src/react/use-client-session.ts +201 -0
  115. package/src/react/use-create-view.ts +72 -0
  116. package/src/react/use-tree.ts +84 -0
  117. package/src/react/use-view.ts +275 -0
  118. package/src/react/vite.config.ts +4 -1
  119. package/src/utils.ts +63 -45
  120. package/src/vercel/codec/decoder.ts +336 -255
  121. package/src/vercel/codec/encoder.ts +348 -196
  122. package/src/vercel/codec/events.ts +87 -0
  123. package/src/vercel/codec/index.ts +59 -14
  124. package/src/vercel/codec/reducer.ts +977 -0
  125. package/src/vercel/codec/tool-transitions.ts +122 -0
  126. package/src/vercel/index.ts +7 -3
  127. package/src/vercel/react/contexts/chat-transport-context.ts +41 -0
  128. package/src/vercel/react/contexts/chat-transport-provider.tsx +150 -0
  129. package/src/vercel/react/index.ts +13 -1
  130. package/src/vercel/react/use-chat-transport.ts +162 -42
  131. package/src/vercel/react/use-message-sync.ts +121 -22
  132. package/src/vercel/react/vite.config.ts +4 -2
  133. package/src/vercel/run-end-reason.ts +78 -0
  134. package/src/vercel/transport/chat-transport.ts +553 -113
  135. package/src/vercel/transport/index.ts +40 -28
  136. package/src/vercel/transport/run-output-stream.ts +170 -0
  137. package/src/version.ts +2 -0
  138. package/dist/core/transport/client-transport.d.ts +0 -10
  139. package/dist/core/transport/conversation-tree.d.ts +0 -9
  140. package/dist/core/transport/decode-history.d.ts +0 -41
  141. package/dist/core/transport/server-transport.d.ts +0 -7
  142. package/dist/core/transport/stream-router.d.ts +0 -19
  143. package/dist/core/transport/turn-manager.d.ts +0 -34
  144. package/dist/react/use-active-turns.d.ts +0 -8
  145. package/dist/react/use-client-transport.d.ts +0 -7
  146. package/dist/react/use-conversation-tree.d.ts +0 -20
  147. package/dist/react/use-edit.d.ts +0 -7
  148. package/dist/react/use-history.d.ts +0 -19
  149. package/dist/react/use-messages.d.ts +0 -7
  150. package/dist/react/use-regenerate.d.ts +0 -7
  151. package/dist/react/use-send.d.ts +0 -7
  152. package/dist/vercel/codec/accumulator.d.ts +0 -21
  153. package/src/core/transport/client-transport.ts +0 -959
  154. package/src/core/transport/conversation-tree.ts +0 -434
  155. package/src/core/transport/decode-history.ts +0 -337
  156. package/src/core/transport/server-transport.ts +0 -458
  157. package/src/core/transport/stream-router.ts +0 -118
  158. package/src/core/transport/turn-manager.ts +0 -147
  159. package/src/react/use-active-turns.ts +0 -61
  160. package/src/react/use-client-transport.ts +0 -37
  161. package/src/react/use-conversation-tree.ts +0 -71
  162. package/src/react/use-edit.ts +0 -24
  163. package/src/react/use-history.ts +0 -111
  164. package/src/react/use-messages.ts +0 -32
  165. package/src/react/use-regenerate.ts +0 -24
  166. package/src/react/use-send.ts +0 -25
  167. package/src/vercel/codec/accumulator.ts +0 -603
@@ -0,0 +1,775 @@
1
+ /**
2
+ * Core client-side session, parameterized by codec.
3
+ *
4
+ * Composes the conversation Tree to handle the full client-side lifecycle.
5
+ * `connect()` subscribes to the Ably channel (which implicitly attaches it).
6
+ * The same subscription, decoder, and channel are reused across runs.
7
+ *
8
+ * The client publishes user messages directly to the channel via the shared
9
+ * codec encoder. It does not send HTTP: waking an agent is the application's
10
+ * concern — it POSTs `run.toInvocation().toJSON()` to its own endpoint if and
11
+ * when it wants one woken (the Vercel ChatTransport does this for useChat
12
+ * parity). The agent locates the triggering input event by its `event-id`
13
+ * header and publishes run lifecycle events (run-start, run-end) plus assistant
14
+ * chunks, minting and stamping the invocation-id itself. The channel is the
15
+ * durable session record; agents that weren't running at publish time can
16
+ * resume by reading channel rewind.
17
+ */
18
+
19
+ import * as Ably from 'ably';
20
+
21
+ import {
22
+ EVENT_CANCEL,
23
+ EVENT_RUN_END,
24
+ HEADER_CODEC_MESSAGE_ID,
25
+ HEADER_ERROR_CODE,
26
+ HEADER_ERROR_MESSAGE,
27
+ HEADER_EVENT_ID,
28
+ HEADER_INPUT_CODEC_MESSAGE_ID,
29
+ HEADER_INVOCATION_ID,
30
+ HEADER_PARENT,
31
+ HEADER_ROLE,
32
+ HEADER_RUN_ID,
33
+ HEADER_RUN_REASON,
34
+ } from '../../constants.js';
35
+ import { ErrorCode } from '../../errors.js';
36
+ import { EventEmitter } from '../../event-emitter.js';
37
+ import type { Logger } from '../../logger.js';
38
+ import { LogLevel, makeLogger } from '../../logger.js';
39
+ import { getTransportHeaders } from '../../utils.js';
40
+ import { registerAgent } from '../agent.js';
41
+ import type { CodecInputEvent, CodecOutputEvent, Decoder, Encoder } from '../codec/types.js';
42
+ import { applyWireMessage } from './decode-fold.js';
43
+ import { buildTransportHeaders } from './headers.js';
44
+ import { Invocation } from './invocation.js';
45
+ import type { DefaultTree } from './tree.js';
46
+ import { createTree } from './tree.js';
47
+ import type { ActiveRun, ClientSession, ClientSessionOptions, RunEndReason, SendOptions, Tree, View } from './types.js';
48
+ import { createView, type DefaultView } from './view.js';
49
+
50
+ /**
51
+ * Returned from `on()` when the session is already closed — the subscription
52
+ * is silently ignored since no further events will fire.
53
+ */
54
+ // eslint-disable-next-line @typescript-eslint/no-empty-function -- intentional no-op
55
+ const noopUnsubscribe = (): void => {};
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Internal state machine
59
+ // ---------------------------------------------------------------------------
60
+
61
+ enum ClientSessionState {
62
+ READY = 'ready',
63
+ CLOSED = 'closed',
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Event map for the session's typed EventEmitter
68
+ // ---------------------------------------------------------------------------
69
+
70
+ interface ClientSessionEventsMap {
71
+ error: Ably.ErrorInfo;
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Implementation
76
+ // ---------------------------------------------------------------------------
77
+
78
+ // Spec: AIT-CT1
79
+ class DefaultClientSession<
80
+ TInput extends CodecInputEvent,
81
+ TOutput extends CodecOutputEvent,
82
+ TProjection,
83
+ TMessage,
84
+ > implements ClientSession<TInput, TOutput, TProjection, TMessage> {
85
+ private readonly _channel: Ably.RealtimeChannel;
86
+ private readonly _codec: ClientSessionOptions<TInput, TOutput, TProjection, TMessage>['codec'];
87
+ private readonly _clientId: string | undefined;
88
+ private readonly _logger: Logger;
89
+
90
+ // Typed event emitter — the session emits only 'error'; all data events live on Tree/View
91
+ private readonly _emitter: EventEmitter<ClientSessionEventsMap>;
92
+
93
+ // Sub-components
94
+ private readonly _tree: DefaultTree<TInput, TOutput, TProjection>;
95
+ private readonly _view: DefaultView<TInput, TOutput, TProjection, TMessage>;
96
+ private readonly _views = new Set<DefaultView<TInput, TOutput, TProjection, TMessage>>();
97
+ private readonly _decoder: Decoder<TInput, TOutput>;
98
+ /**
99
+ * Shared encoder for the lifetime of the session. The client only ever
100
+ * uses `publishInput` (input wire), so the encoder's stream tracker map
101
+ * stays empty across the session. Closed once on session close.
102
+ */
103
+ private readonly _encoder: Encoder<TInput, TOutput>;
104
+
105
+ // Spec: AIT-CT10, AIT-CT10a
106
+ readonly tree: Tree<TOutput, TProjection>;
107
+ readonly view: View<TInput, TMessage>;
108
+
109
+ // Channel subscription is established lazily on connect()
110
+ private _connectPromise: Promise<void> | undefined;
111
+ private readonly _onMessage: (msg: Ably.InboundMessage) => void;
112
+
113
+ private _state = ClientSessionState.READY;
114
+ private _hasAttachedOnce: boolean;
115
+ private readonly _onChannelStateChange: Ably.channelEventCallback;
116
+
117
+ /**
118
+ * Backing settlers for each in-flight run's `ActiveRun.runId` promise.
119
+ * Resolved with the agent-minted run-id when the matching `ai-run-start`
120
+ * (fresh send) or `ai-run-resume` (continuation) is observed; rejected if
121
+ * the session closes first. There is no deadline —
122
+ * `send()` resolves on publish and does not block on run-start.
123
+ *
124
+ * Keyed by the triggering input's codec-message-id — the handle the client
125
+ * owns at send time, which the agent echoes back on run-start as
126
+ * `input-codec-message-id`. This is uniform across fresh sends and
127
+ * continuations (a continuation is itself an input event — tool-approval or
128
+ * tool-result — with its own codec-message-id), so reconciliation never
129
+ * depends on a client-minted run/invocation id.
130
+ */
131
+ private readonly _pendingRunStarts = new Map<
132
+ string,
133
+ { resolve: (runId: string) => void; reject: (e: Ably.ErrorInfo) => void }
134
+ >();
135
+
136
+ constructor(options: ClientSessionOptions<TInput, TOutput, TProjection, TMessage>) {
137
+ // Spec: AIT-CT1a, AIT-CT1a2 — register this SDK on both the connection
138
+ // (options.agents) and channel-attach (params.agent) paths. Idempotent
139
+ // across sessions sharing one client.
140
+ const channelOptions = registerAgent(options.client, options.codec);
141
+ this._channel = options.client.channels.get(options.channelName, channelOptions);
142
+ this._codec = options.codec;
143
+ this._clientId = options.clientId;
144
+ this._logger = (options.logger ?? makeLogger({ logLevel: LogLevel.Silent })).withContext({
145
+ component: 'ClientSession',
146
+ });
147
+
148
+ this._emitter = new EventEmitter<ClientSessionEventsMap>(this._logger);
149
+ this._hasAttachedOnce = this._channel.state === 'attached';
150
+
151
+ // Compose sub-components
152
+ this._tree = createTree<TInput, TOutput, TProjection>(this._codec, this._logger);
153
+ this._view = createView<TInput, TOutput, TProjection, TMessage>({
154
+ tree: this._tree,
155
+ channel: this._channel,
156
+ codec: this._codec,
157
+ sendDelegate: this._internalSend.bind(this),
158
+ logger: this._logger,
159
+ onClose: () => this._views.delete(this._view),
160
+ });
161
+ this._decoder = this._codec.createDecoder();
162
+ this._encoder = this._codec.createEncoder(
163
+ this._channel,
164
+ this._clientId === undefined ? undefined : { clientId: this._clientId },
165
+ );
166
+
167
+ this._views.add(this._view);
168
+
169
+ // Public accessors (typed as narrow interfaces)
170
+ this.tree = this._tree;
171
+ this.view = this._view;
172
+
173
+ // Seed tree with initial messages — the session assigns a codecMessageId
174
+ // per seed message. Each seed becomes a run-less input node (no run-id —
175
+ // the client never mints one); the parent chain mirrors the original seed
176
+ // sequence (a user→user input chain the Tree threads kind-blind).
177
+ if (options.messages) {
178
+ let prevMsgId: string | undefined;
179
+ for (const msg of options.messages) {
180
+ const codecMessageId = crypto.randomUUID();
181
+ const seedHeaders: Record<string, string> = {
182
+ [HEADER_CODEC_MESSAGE_ID]: codecMessageId,
183
+ [HEADER_ROLE]: 'user',
184
+ };
185
+ if (prevMsgId) seedHeaders[HEADER_PARENT] = prevMsgId;
186
+ this._tree.applyMessage({ inputs: [this._codec.createUserMessage(msg)], outputs: [] }, seedHeaders);
187
+ prevMsgId = codecMessageId;
188
+ }
189
+ }
190
+
191
+ // Spec: AIT-CT2
192
+ // Listener function reference — bound now so it can be unsubscribed on close.
193
+ this._onMessage = (ablyMessage: Ably.InboundMessage) => {
194
+ this._handleMessage(ablyMessage);
195
+ };
196
+
197
+ // Listen for channel state changes that break message continuity.
198
+ // _hasAttachedOnce is seeded from the channel's current state so that
199
+ // pre-attached channels are handled correctly. It distinguishes the
200
+ // initial attach (expected) from a genuine discontinuity.
201
+ this._onChannelStateChange = (stateChange: Ably.ChannelStateChange) => {
202
+ this._handleChannelStateChange(stateChange);
203
+ };
204
+ this._channel.on(this._onChannelStateChange);
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Public connection API
209
+ // ---------------------------------------------------------------------------
210
+
211
+ // Spec: AIT-CT2
212
+ // eslint-disable-next-line @typescript-eslint/promise-function-async -- preserve reference equality across calls
213
+ connect(): Promise<void> {
214
+ if (this._state === ClientSessionState.CLOSED) {
215
+ return Promise.reject(new Ably.ErrorInfo('unable to connect; session is closed', ErrorCode.SessionClosed, 400));
216
+ }
217
+ if (this._connectPromise) return this._connectPromise;
218
+
219
+ this._logger.trace('DefaultClientSession.connect();');
220
+ // Subscribe before attach (RTL7g) — subscribe implicitly attaches the channel.
221
+ this._connectPromise = this._channel.subscribe(this._onMessage).then(
222
+ () => {
223
+ this._logger.debug('DefaultClientSession.connect(); subscribed and attached');
224
+ },
225
+ (error: unknown) => {
226
+ const errInfo = new Ably.ErrorInfo(
227
+ `unable to subscribe to channel; ${error instanceof Error ? error.message : String(error)}`,
228
+ ErrorCode.SessionSubscriptionError,
229
+ 500,
230
+ error instanceof Ably.ErrorInfo ? error : undefined,
231
+ );
232
+ this._logger.error('DefaultClientSession.connect(); subscribe failed');
233
+ this._emitter.emit('error', errInfo);
234
+ throw errInfo;
235
+ },
236
+ );
237
+ return this._connectPromise;
238
+ }
239
+
240
+ private async _requireConnected(method: string): Promise<void> {
241
+ if (!this._connectPromise) {
242
+ throw new Ably.ErrorInfo(
243
+ `unable to ${method}; connect() must be called before ${method}()`,
244
+ ErrorCode.InvalidArgument,
245
+ 400,
246
+ );
247
+ }
248
+ return this._connectPromise;
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Message subscription handler
253
+ // ---------------------------------------------------------------------------
254
+
255
+ private _handleMessage(ablyMessage: Ably.InboundMessage): void {
256
+ if (this._state === ClientSessionState.CLOSED) return;
257
+
258
+ try {
259
+ // Spec: AIT-CT16a
260
+ // Live-only: surface an agent error carried on a run-end BEFORE applying
261
+ // it, preserving the original 'error'-before-tree-'run' emit ordering.
262
+ // Consumers that expose a per-run stream (e.g. the Vercel ChatTransport)
263
+ // error their stream off this event. The agent only publishes run-end
264
+ // after run-start, so no pending-run-start tracker is outstanding.
265
+ if (ablyMessage.name === EVENT_RUN_END) {
266
+ const headers = getTransportHeaders(ablyMessage);
267
+ // CAST: agent always writes a valid RunEndReason; default to 'complete' for robustness
268
+ const reason = (headers[HEADER_RUN_REASON] ?? 'complete') as RunEndReason;
269
+ if (reason === 'error') {
270
+ const codeRaw = headers[HEADER_ERROR_CODE];
271
+ const parsedCode = codeRaw === undefined ? Number.NaN : Number(codeRaw);
272
+ const code = Number.isFinite(parsedCode) ? parsedCode : ErrorCode.SessionSubscriptionError;
273
+ const message = headers[HEADER_ERROR_MESSAGE] ?? 'agent reported an error';
274
+ const statusCode = code >= 10000 && code < 60000 ? Math.floor(code / 100) : 500;
275
+ const errInfo = new Ably.ErrorInfo(message, code, statusCode);
276
+ this._logger.error('ClientSession._handleMessage(); agent error received', {
277
+ runId: headers[HEADER_RUN_ID],
278
+ invocationId: headers[HEADER_INVOCATION_ID],
279
+ code,
280
+ });
281
+ this._emitter.emit('error', errInfo);
282
+ }
283
+ }
284
+
285
+ // Reconstruct the tree via the shared decode-fold engine — the same path
286
+ // the View's history replay uses, so the live loop can't drift from it.
287
+ const event = applyWireMessage(this._tree, this._decoder, ablyMessage);
288
+
289
+ // Live-only: resolve the pending `runId` promise on a fresh run-start or
290
+ // a continuation run-resume. Key by the echoed `input-codec-message-id`
291
+ // — the mirror of the arming key on `_pendingRunStarts` (see that
292
+ // field's JSDoc). Every send carries at least one input, so the agent
293
+ // always echoes it.
294
+ if (event && (event.type === 'start' || event.type === 'resume')) {
295
+ const startedKey = getTransportHeaders(ablyMessage)[HEADER_INPUT_CODEC_MESSAGE_ID];
296
+ if (startedKey !== undefined) {
297
+ const pending = this._pendingRunStarts.get(startedKey);
298
+ if (pending) {
299
+ this._pendingRunStarts.delete(startedKey);
300
+ // Resolve the run handle's `runId` promise with the agent-minted id.
301
+ pending.resolve(event.runId);
302
+ }
303
+ }
304
+ }
305
+
306
+ // Emit ably-message AFTER the apply so View subscribers can find the
307
+ // owning node in `_lastVisibleNodeKeySet` (keyed by run-id for reply runs
308
+ // and codec-message-id for inputs), which is refreshed by the tree
309
+ // 'update' events the apply triggers.
310
+ this._tree.emitAblyMessage(ablyMessage);
311
+ } catch (error) {
312
+ const cause = error instanceof Ably.ErrorInfo ? error : undefined;
313
+ this._emitter.emit(
314
+ 'error',
315
+ new Ably.ErrorInfo(
316
+ `unable to process channel message; ${error instanceof Error ? error.message : String(error)}`,
317
+ ErrorCode.SessionSubscriptionError,
318
+ 500,
319
+ cause,
320
+ ),
321
+ );
322
+ }
323
+ }
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // Channel state change handler
327
+ // ---------------------------------------------------------------------------
328
+
329
+ // Spec: AIT-CT19, AIT-CT19a
330
+ private _handleChannelStateChange(stateChange: Ably.ChannelStateChange): void {
331
+ if (this._state === ClientSessionState.CLOSED) return;
332
+
333
+ const { current, resumed } = stateChange;
334
+
335
+ // Track the initial attach so we don't treat it as a discontinuity
336
+ if (current === 'attached' && !this._hasAttachedOnce) {
337
+ this._hasAttachedOnce = true;
338
+ return;
339
+ }
340
+
341
+ // Continuity-breaking states:
342
+ // - FAILED, SUSPENDED, DETACHED: no more messages expected (or gap)
343
+ // - ATTACHED with resumed: false (UPDATE): messages were lost
344
+ const continuityLost =
345
+ current === 'failed' || current === 'suspended' || current === 'detached' || (current === 'attached' && !resumed);
346
+
347
+ if (!continuityLost) return;
348
+
349
+ this._logger.error('ClientSession._handleChannelStateChange(); channel continuity lost', {
350
+ current,
351
+ resumed,
352
+ previous: stateChange.previous,
353
+ });
354
+
355
+ const err = new Ably.ErrorInfo(
356
+ `unable to deliver events; channel continuity lost (${current}${current === 'attached' ? ', resumed: false' : ''})`,
357
+ ErrorCode.ChannelContinuityLost,
358
+ 500,
359
+ stateChange.reason,
360
+ );
361
+
362
+ // Surface the loss via the session `error` event. Consumers that expose a
363
+ // per-run stream (e.g. the Vercel ChatTransport) error their stream off
364
+ // this event; observer-run state lives entirely in the Tree's projection
365
+ // and stays consistent regardless of continuity loss.
366
+ this._emitter.emit('error', err);
367
+ }
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // Cancel helpers
371
+ // ---------------------------------------------------------------------------
372
+
373
+ /**
374
+ * Tear down local state for a send whose channel publish failed.
375
+ * Idempotent.
376
+ * @param codecMessageIds - The codec-message-ids of the failed send's
377
+ * optimistic input nodes (the client mints no run-id, so the optimistic
378
+ * inserts are keyed by their codec-message-ids).
379
+ */
380
+ private _cleanupFailedSend(codecMessageIds: string[]): void {
381
+ for (const codecMessageId of codecMessageIds) {
382
+ // Drop the optimistic input node only if the publish never produced a
383
+ // server-assigned serial (i.e. nothing live observed it). A server-acked
384
+ // node is part of the canonical channel state and must stay; the View /
385
+ // observers already see it. A fresh send's optimistic inserts are input
386
+ // nodes (keyed by codec-message-id).
387
+ const node = this._tree.getNodeByCodecMessageId(codecMessageId);
388
+ if (node?.kind === 'input' && node.serial === undefined) {
389
+ // An input node's key is its codec-message-id, so delete by it directly.
390
+ this._tree.delete(node.codecMessageId);
391
+ }
392
+ }
393
+ }
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Public API
397
+ // ---------------------------------------------------------------------------
398
+
399
+ // Spec: AIT-CT10b
400
+ createView(): View<TInput, TMessage> {
401
+ if (this._state === ClientSessionState.CLOSED) {
402
+ throw new Ably.ErrorInfo('unable to create view; session is closed', ErrorCode.SessionClosed, 400);
403
+ }
404
+ this._logger.trace('DefaultClientSession.createView();');
405
+ const view = createView<TInput, TOutput, TProjection, TMessage>({
406
+ tree: this._tree,
407
+ channel: this._channel,
408
+ codec: this._codec,
409
+ sendDelegate: this._internalSend.bind(this),
410
+ logger: this._logger,
411
+ onClose: () => this._views.delete(view),
412
+ });
413
+ this._views.add(view);
414
+ return view;
415
+ }
416
+
417
+ // Spec: AIT-CT3, AIT-CT4
418
+ private async _internalSend(
419
+ input: TInput[],
420
+ sendOptions: SendOptions | undefined,
421
+ parentCodecMessageId: string | undefined,
422
+ ): Promise<ActiveRun> {
423
+ if (this._state === ClientSessionState.CLOSED) {
424
+ throw new Ably.ErrorInfo('unable to send; session is closed', ErrorCode.SessionClosed, 400);
425
+ }
426
+ await this._requireConnected('send');
427
+ // CAST: re-check after await — close() may have been called while waiting for connect.
428
+ // TypeScript's control flow narrows _state after the first check, but the
429
+ // await yields and close() can mutate _state concurrently.
430
+ if ((this._state as ClientSessionState) === ClientSessionState.CLOSED) {
431
+ throw new Ably.ErrorInfo('unable to send; session is closed', ErrorCode.SessionClosed, 400);
432
+ }
433
+
434
+ // Spec: AIT-CT20
435
+ const state = this._channel.state;
436
+ if (state !== 'attached' && state !== 'attaching') {
437
+ throw new Ably.ErrorInfo(`unable to send; channel is ${state}`, ErrorCode.ChannelNotReady, 400);
438
+ }
439
+
440
+ this._logger.trace('ClientSession._internalSend();');
441
+
442
+ const isContinuation = sendOptions?.runId !== undefined;
443
+
444
+ // The agent mints run-ids, not the client. A fresh send carries no run-id
445
+ // (the agent mints it and echoes it on run-start); only a continuation
446
+ // reuses the existing run-id the caller passed.
447
+ const runId = sendOptions?.runId;
448
+
449
+ // Spec: AIT-CT3d
450
+ // Auto-compute parent from the visible branch tail when not explicitly
451
+ // provided. The View pre-resolves the codec-message-id of the last visible message
452
+ // since the session is codec-agnostic and can't extract it from TMessage.
453
+ let autoParent: string | undefined;
454
+ if (sendOptions?.parent === undefined && !sendOptions?.forkOf) {
455
+ autoParent = parentCodecMessageId;
456
+ }
457
+
458
+ const codecMessageIds = new Set<string>();
459
+ interface ItemState {
460
+ input: TInput;
461
+ codecMessageId: string;
462
+ inputEventId: string;
463
+ headers: Record<string, string>;
464
+ /** Inputs that reference an existing codec-message without contributing fresh local content (regenerate, tool resolutions) are wire-only — no optimistic projection fold. Fresh user-messages always fold, even when they pin their own codecMessageId. */
465
+ isWireOnly: boolean;
466
+ }
467
+ const items: ItemState[] = [];
468
+
469
+ // Per-input wire prep: read routing fields off the input directly, then
470
+ // mint per-event ids and build transport headers. Regenerate inputs are
471
+ // wire-only (no optimistic fold); other inputs fold into the projection
472
+ // optimistically.
473
+ for (const entry of input) {
474
+ const inputEventId = crypto.randomUUID();
475
+ // Use the input's `codecMessageId` when set (e.g. tool resolution
476
+ // targeting the prior assistant); otherwise mint a fresh id.
477
+ const codecMessageId = entry.codecMessageId ?? crypto.randomUUID();
478
+ codecMessageIds.add(codecMessageId);
479
+
480
+ // Inputs that reference an existing message (regenerate, tool
481
+ // resolutions targeting an assistant) are wire-only — no optimistic
482
+ // fold needed because either the receiving content doesn't
483
+ // materialise on this side (regenerate) or the target already exists
484
+ // and will be amended when the wire echoes back.
485
+ //
486
+ // A fresh `user-message` is never wire-only, even on the rare path
487
+ // where it carries an explicit `codecMessageId`: it is new content that
488
+ // must fold into the local projection immediately. Excluding it here
489
+ // keeps the optimistic user bubble from depending on the channel
490
+ // round-trip. (The session mints the codec-message-id for fresh user
491
+ // messages; the caller's `message.id` is preserved but never used as
492
+ // the correlation key.)
493
+ const isWireOnly =
494
+ entry.kind !== 'user-message' && (entry.kind === 'regenerate' || entry.codecMessageId !== undefined);
495
+
496
+ // The input's own routing fields override the auto-parent /
497
+ // sendOptions defaults. For regenerate inputs, `target` becomes the
498
+ // `msg-regenerate` wire header. The fork anchor comes from
499
+ // `sendOptions.forkOf` (set by `View.edit`). The transport reads
500
+ // these directly without runtime classification.
501
+ const parent = entry.parent ?? (sendOptions?.parent === undefined ? autoParent : sendOptions.parent);
502
+ const forkOf = sendOptions?.forkOf;
503
+ const regenerates = entry.kind === 'regenerate' ? entry.target : undefined;
504
+
505
+ const headers = buildTransportHeaders({
506
+ role: 'user',
507
+ runId,
508
+ codecMessageId,
509
+ runClientId: this._clientId,
510
+ ...(parent !== undefined && { parent }),
511
+ ...(forkOf !== undefined && { forkOf }),
512
+ ...(regenerates !== undefined && { regenerates }),
513
+ inputEventId,
514
+ });
515
+
516
+ // Spec: AIT-CT3c — optimistic fold for non-wire-only inputs.
517
+ if (!isWireOnly) {
518
+ this._tree.applyMessage({ inputs: [entry], outputs: [] }, headers);
519
+ }
520
+
521
+ items.push({ input: entry, codecMessageId, inputEventId, headers, isWireOnly });
522
+
523
+ // Spec: AIT-CT3e — chain subsequent inputs off the previous one when
524
+ // auto-parenting is in effect.
525
+ if (!isWireOnly && sendOptions?.parent === undefined && !sendOptions?.forkOf && entry.parent === undefined) {
526
+ autoParent = codecMessageId;
527
+ }
528
+ }
529
+
530
+ // The trigger event is the last input — the one the agent looks up on the
531
+ // channel via `event-id`, surfaced on `ActiveRun` (and via `toInvocation()`)
532
+ // so the application can point an invocation at it. Its codec-message-id is
533
+ // the handle the client owns at send time; the agent echoes it back on
534
+ // run-start as `input-codec-message-id`, and it keys the run-start tracker.
535
+ const triggerItem = items.at(-1);
536
+ if (triggerItem === undefined) {
537
+ // Every send must carry at least one input — only new input starts or
538
+ // continues a run. The loop above produced no items, so nothing was
539
+ // published or folded optimistically.
540
+ throw new Ably.ErrorInfo(
541
+ 'unable to send; inputs array is empty (include at least one input)',
542
+ ErrorCode.InvalidArgument,
543
+ 400,
544
+ );
545
+ }
546
+ const triggerInputEventId = triggerItem.inputEventId;
547
+ const startedKey = triggerItem.codecMessageId;
548
+
549
+ // Arm the run-start tracker backing the returned `ActiveRun.runId` promise.
550
+ // The run-start handler resolves it with the agent-minted run-id when this
551
+ // send's `ai-run-start` is observed; close() rejects it on teardown. No
552
+ // deadline — `send()` resolves on publish; callers bound the wait by racing
553
+ // `run.runId` against their own timeout.
554
+ //
555
+ // Key on the arming side mirrors the resolve side — see `_pendingRunStarts`
556
+ // for the full keying invariant. The executor runs synchronously, so the
557
+ // tracker entry is registered before `new Promise` returns.
558
+ const runIdPromise = new Promise<string>((resolve, reject) => {
559
+ this._pendingRunStarts.set(startedKey, { resolve, reject });
560
+ });
561
+ // Suppress unhandled-rejection warnings for callers that never await
562
+ // `run.runId`; the caller still observes the rejection if it does await.
563
+ runIdPromise.catch(() => {
564
+ /* observed via run.runId, if at all */
565
+ });
566
+
567
+ // Publish each input in original order via the shared encoder. The
568
+ // codec routes user-message inputs into a per-part discrete batch and
569
+ // tool-resolution / regenerate inputs into a single discrete write —
570
+ // all on the `ai-input` wire.
571
+ const publishPromise = (async () => {
572
+ try {
573
+ for (const item of items) {
574
+ await this._encoder.publishInput(item.input, {
575
+ extras: { headers: item.headers },
576
+ messageId: item.codecMessageId,
577
+ ...(this._clientId !== undefined && { clientId: this._clientId }),
578
+ });
579
+ }
580
+ } catch (error) {
581
+ const cause = error instanceof Ably.ErrorInfo ? error : undefined;
582
+ const isPermission = cause?.statusCode === 401 || cause?.statusCode === 403;
583
+ const err = new Ably.ErrorInfo(
584
+ isPermission
585
+ ? `unable to publish events; missing publish capability on the channel`
586
+ : `unable to publish events; ${error instanceof Error ? error.message : String(error)}`,
587
+ isPermission ? ErrorCode.InsufficientCapability : ErrorCode.SessionSendFailed,
588
+ isPermission ? 401 : 500,
589
+ cause,
590
+ );
591
+ this._emitter.emit('error', err);
592
+ // The input never reached the channel — there is no run to wait on.
593
+ // Drop the run-start tracker so close() doesn't later reject an orphan.
594
+ this._pendingRunStarts.delete(startedKey);
595
+ // Continuations didn't insert optimistic nodes, so there is nothing to
596
+ // clear for them — only a fresh send's optimistic input nodes need
597
+ // removing, keyed by their codec-message-ids (the client mints no runId).
598
+ if (!isContinuation) this._cleanupFailedSend([...codecMessageIds]);
599
+ throw err;
600
+ }
601
+ })();
602
+
603
+ // `send()` resolves once the input is published. The core never sends
604
+ // HTTP — waking an agent is the application's concern. Callers POST
605
+ // `run.toInvocation().toJSON()` to their endpoint if they want one woken,
606
+ // and await `run.runId` if they need to know it was picked up.
607
+ await publishPromise;
608
+
609
+ return {
610
+ inputCodecMessageId: startedKey,
611
+ runId: runIdPromise,
612
+ inputEventId: triggerInputEventId,
613
+ // The agent mints the run-id, so a fresh run has none until run-start.
614
+ // Cancel synchronously by the triggering input's codec-message-id (the
615
+ // handle the client owns at send time, = `inputCodecMessageId`): the
616
+ // agent resolves it to the run once its input-event lookup completes, and
617
+ // buffers a cancel that arrives before then so an early cancel is honoured
618
+ // rather than dropped. A continuation additionally carries its known
619
+ // run-id so the agent can match the run directly.
620
+ cancel: async () => {
621
+ await this._publishCancel({
622
+ inputCodecMessageId: startedKey,
623
+ ...(runId !== undefined && { runId }),
624
+ });
625
+ },
626
+ optimisticCodecMessageIds: [...codecMessageIds],
627
+ toInvocation: () =>
628
+ // The invocation body carries no run-id: run identity lives on the
629
+ // channel (the agent mints a fresh run-id, or reads a continuation's
630
+ // from the triggering input event, which carries the reused run-id).
631
+ Invocation.fromJSON({
632
+ inputEventId: triggerInputEventId,
633
+ sessionName: this._channel.name,
634
+ }),
635
+ };
636
+ }
637
+
638
+ // Spec: AIT-CT7, AIT-CT7a
639
+ async cancel(runId: string): Promise<void> {
640
+ return this._publishCancel({ runId });
641
+ }
642
+
643
+ /**
644
+ * Publish an `ai-cancel` signal. The agent resolves the target run by
645
+ * whichever identifier is present:
646
+ *
647
+ * - `runId` — a continuation, whose run-id the caller already knows.
648
+ * - `inputCodecMessageId` — a fresh send, whose run-id the agent mints at
649
+ * run-start. The client can only key the cancel by the triggering input's
650
+ * codec-message-id (the `ActiveRun.inputCodecMessageId`) it owns at send
651
+ * time; the agent resolves it to the run once its input-event lookup
652
+ * completes, buffering a cancel that arrives before then.
653
+ *
654
+ * Both may be present (a continuation knows its run-id AND published an
655
+ * input). An `event-id` is always stamped so channel rewind redelivers the
656
+ * cancel to a per-request / serverless agent that attaches after it was
657
+ * published.
658
+ *
659
+ * Publishing the cancel signal is all the core does. The consumer-facing
660
+ * stream (if any) lives in the layer that built it — e.g. the Vercel
661
+ * ChatTransport closes its stream on cancel — and the Tree's RunNode is left
662
+ * intact so late agent events (a cancel append, a trailing
663
+ * `status: cancelled`) still fold into the Run's projection.
664
+ * @param target - The run identifier(s) to cancel. At least one of `runId` /
665
+ * `inputCodecMessageId` must be set.
666
+ * @param target.runId - The run-id to cancel (continuations).
667
+ * @param target.inputCodecMessageId - The triggering input's
668
+ * codec-message-id to cancel (fresh sends, before run-start).
669
+ */
670
+ private async _publishCancel(target: { runId?: string; inputCodecMessageId?: string }): Promise<void> {
671
+ if (this._state === ClientSessionState.CLOSED) return;
672
+ await this._requireConnected('cancel');
673
+ // CAST: re-check after await — close() may have been called while waiting for connect.
674
+ if ((this._state as ClientSessionState) === ClientSessionState.CLOSED) return;
675
+ this._logger.debug('ClientSession._publishCancel();', {
676
+ runId: target.runId,
677
+ inputCodecMessageId: target.inputCodecMessageId,
678
+ });
679
+
680
+ const headers: Record<string, string> = {
681
+ // Stamp a per-cancel event-id so channel rewind redelivers this cancel
682
+ // to an agent that attaches after it was published.
683
+ [HEADER_EVENT_ID]: crypto.randomUUID(),
684
+ };
685
+ if (target.runId !== undefined) headers[HEADER_RUN_ID] = target.runId;
686
+ if (target.inputCodecMessageId !== undefined) headers[HEADER_INPUT_CODEC_MESSAGE_ID] = target.inputCodecMessageId;
687
+
688
+ await this._channel.publish({
689
+ name: EVENT_CANCEL,
690
+ extras: { ai: { transport: headers } },
691
+ });
692
+ }
693
+
694
+ // Spec: AIT-CT8, AIT-CT8c, AIT-CT8d
695
+ on(event: 'error', handler: (error: Ably.ErrorInfo) => void): () => void {
696
+ if (this._state === ClientSessionState.CLOSED) return noopUnsubscribe;
697
+ // CAST: the overload signature enforces the correct handler type.
698
+ const cb = handler;
699
+ this._emitter.on(event, cb);
700
+ return () => {
701
+ this._emitter.off(event, cb);
702
+ };
703
+ }
704
+
705
+ // Spec: AIT-CT12, AIT-CT12b, AIT-CT10c
706
+ async close(): Promise<void> {
707
+ if (this._state === ClientSessionState.CLOSED) return;
708
+ this._state = ClientSessionState.CLOSED;
709
+ this._logger.info('ClientSession.close();');
710
+
711
+ if (this._connectPromise) {
712
+ this._channel.unsubscribe(this._onMessage);
713
+ }
714
+ this._channel.off(this._onChannelStateChange);
715
+
716
+ this._emitter.off();
717
+ for (const v of this._views) v.close();
718
+ this._views.clear();
719
+ // Reject any in-flight `run.runId` promises so callers awaiting run-start
720
+ // settle rather than hang.
721
+ if (this._pendingRunStarts.size > 0) {
722
+ const closedErr = new Ably.ErrorInfo('unable to await run-start; session closed', ErrorCode.SessionClosed, 400);
723
+ for (const pending of this._pendingRunStarts.values()) {
724
+ pending.reject(closedErr);
725
+ }
726
+ this._pendingRunStarts.clear();
727
+ }
728
+
729
+ // Best-effort encoder close — flushes any pending stream operations.
730
+ // The client only uses the discrete input path (publishInput), so this is
731
+ // typically a no-op, but it releases any internal resources cleanly.
732
+ try {
733
+ await this._encoder.close();
734
+ } catch {
735
+ // Swallow: encoder close is best-effort during teardown
736
+ }
737
+
738
+ // Detach the channel this session attached. connect() subscribes (which
739
+ // implicitly attaches), so we only detach when connect() ran. Best-effort:
740
+ // a detach failure (e.g. the channel is already FAILED) must not throw out
741
+ // of close().
742
+ if (this._connectPromise) {
743
+ try {
744
+ await this._channel.detach();
745
+ } catch (error) {
746
+ // Swallowed (see above): a detach failure must not throw out of
747
+ // close(). Logged at debug for observability.
748
+ this._logger.debug('ClientSession.close(); channel detach failed', { error });
749
+ }
750
+ }
751
+ }
752
+ }
753
+
754
+ // ---------------------------------------------------------------------------
755
+ // Factory
756
+ // ---------------------------------------------------------------------------
757
+
758
+ /**
759
+ * Create a client-side session that manages conversation state over an Ably channel.
760
+ *
761
+ * The caller owns the client's lifecycle; the session owns its channel.
762
+ * The session is created in a not-yet-connected state — callers must
763
+ * `await session.connect()` before `send`, `regenerate`, `edit`, `update`,
764
+ * or `cancel`.
765
+ * @param options - Configuration for the client session.
766
+ * @returns A new {@link ClientSession} instance.
767
+ */
768
+ export const createClientSession = <
769
+ TInput extends CodecInputEvent,
770
+ TOutput extends CodecOutputEvent,
771
+ TProjection,
772
+ TMessage,
773
+ >(
774
+ options: ClientSessionOptions<TInput, TOutput, TProjection, TMessage>,
775
+ ): ClientSession<TInput, TOutput, TProjection, TMessage> => new DefaultClientSession(options);