@ably/ai-transport 0.1.0 → 0.3.0

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