@ably/ai-transport 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/README.md +10 -19
  2. package/dist/ably-ai-transport.js +1790 -1091
  3. package/dist/ably-ai-transport.js.map +1 -1
  4. package/dist/ably-ai-transport.umd.cjs +1 -1
  5. package/dist/ably-ai-transport.umd.cjs.map +1 -1
  6. package/dist/constants.d.ts +2 -2
  7. package/dist/core/agent.d.ts +20 -5
  8. package/dist/core/channel-options.d.ts +57 -0
  9. package/dist/core/codec/codec-event.d.ts +9 -0
  10. package/dist/core/codec/decoder.d.ts +4 -1
  11. package/dist/core/codec/define-codec.d.ts +100 -0
  12. package/dist/core/codec/encoder.d.ts +2 -7
  13. package/dist/core/codec/field-bag.d.ts +85 -0
  14. package/dist/core/codec/fields.d.ts +141 -0
  15. package/dist/core/codec/index.d.ts +8 -1
  16. package/dist/core/codec/input-descriptor-decoder.d.ts +19 -0
  17. package/dist/core/codec/input-descriptor-encoder.d.ts +22 -0
  18. package/dist/core/codec/input-descriptors.d.ts +281 -0
  19. package/dist/core/codec/output-descriptor-decoder.d.ts +29 -0
  20. package/dist/core/codec/output-descriptor-encoder.d.ts +31 -0
  21. package/dist/core/codec/output-descriptors.d.ts +237 -0
  22. package/dist/core/codec/types.d.ts +95 -36
  23. package/dist/core/codec/well-known-inputs.d.ts +52 -0
  24. package/dist/core/transport/agent-view.d.ts +296 -0
  25. package/dist/core/transport/decode-fold.d.ts +40 -32
  26. package/dist/core/transport/headers.d.ts +30 -1
  27. package/dist/core/transport/index.d.ts +1 -1
  28. package/dist/core/transport/invocation.d.ts +1 -1
  29. package/dist/core/transport/load-history-pages.d.ts +71 -0
  30. package/dist/core/transport/load-history.d.ts +21 -16
  31. package/dist/core/transport/run-manager.d.ts +9 -11
  32. package/dist/core/transport/session-support.d.ts +55 -0
  33. package/dist/core/transport/tree.d.ts +165 -15
  34. package/dist/core/transport/types/agent.d.ts +120 -98
  35. package/dist/core/transport/types/client.d.ts +45 -12
  36. package/dist/core/transport/types/tree.d.ts +52 -10
  37. package/dist/core/transport/types/view.d.ts +55 -28
  38. package/dist/core/transport/view.d.ts +176 -58
  39. package/dist/core/transport/wire-log.d.ts +102 -0
  40. package/dist/errors.d.ts +10 -4
  41. package/dist/index.d.ts +6 -5
  42. package/dist/react/ably-ai-transport-react.js +784 -415
  43. package/dist/react/ably-ai-transport-react.js.map +1 -1
  44. package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
  45. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
  46. package/dist/react/contexts/client-session-context.d.ts +2 -1
  47. package/dist/react/contexts/client-session-provider.d.ts +3 -0
  48. package/dist/react/index.d.ts +2 -1
  49. package/dist/react/internal/skipped-session.d.ts +8 -0
  50. package/dist/react/use-view.d.ts +3 -3
  51. package/dist/utils.d.ts +22 -54
  52. package/dist/vercel/ably-ai-transport-vercel.js +2297 -2026
  53. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
  54. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
  55. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
  56. package/dist/vercel/codec/decode-lifecycle.d.ts +9 -0
  57. package/dist/vercel/codec/events.d.ts +1 -2
  58. package/dist/vercel/codec/fields.d.ts +44 -0
  59. package/dist/vercel/codec/fold-content.d.ts +16 -0
  60. package/dist/vercel/codec/fold-data.d.ts +16 -0
  61. package/dist/vercel/codec/fold-input.d.ts +67 -0
  62. package/dist/vercel/codec/fold-lifecycle.d.ts +16 -0
  63. package/dist/vercel/codec/fold-text.d.ts +16 -0
  64. package/dist/vercel/codec/fold-tool-input.d.ts +17 -0
  65. package/dist/vercel/codec/fold-tool-output.d.ts +16 -0
  66. package/dist/vercel/codec/index.d.ts +5 -30
  67. package/dist/vercel/codec/inputs.d.ts +11 -0
  68. package/dist/vercel/codec/outputs.d.ts +11 -0
  69. package/dist/vercel/codec/reducer-state.d.ts +121 -0
  70. package/dist/vercel/codec/reducer.d.ts +20 -102
  71. package/dist/vercel/codec/tool-transitions.d.ts +0 -6
  72. package/dist/vercel/codec/wire-data.d.ts +34 -0
  73. package/dist/vercel/index.d.ts +1 -0
  74. package/dist/vercel/react/ably-ai-transport-vercel-react.js +2013 -9500
  75. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
  76. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +1 -70
  77. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
  78. package/dist/vercel/react/contexts/chat-transport-context.d.ts +2 -1
  79. package/dist/vercel/run-end-reason.d.ts +66 -11
  80. package/dist/vercel/tool-part.d.ts +21 -0
  81. package/dist/vercel/transport/chat-transport.d.ts +0 -2
  82. package/dist/vercel/transport/index.d.ts +1 -1
  83. package/dist/vercel/transport/run-output-stream.d.ts +6 -8
  84. package/dist/version.d.ts +1 -1
  85. package/package.json +2 -2
  86. package/src/constants.ts +2 -2
  87. package/src/core/agent.ts +43 -19
  88. package/src/core/channel-options.ts +89 -0
  89. package/src/core/codec/codec-event.ts +27 -0
  90. package/src/core/codec/decoder.ts +145 -21
  91. package/src/core/codec/define-codec.ts +432 -0
  92. package/src/core/codec/encoder.ts +13 -54
  93. package/src/core/codec/field-bag.ts +142 -0
  94. package/src/core/codec/fields.ts +193 -0
  95. package/src/core/codec/index.ts +43 -0
  96. package/src/core/codec/input-descriptor-decoder.ts +97 -0
  97. package/src/core/codec/input-descriptor-encoder.ts +150 -0
  98. package/src/core/codec/input-descriptors.ts +373 -0
  99. package/src/core/codec/output-descriptor-decoder.ts +139 -0
  100. package/src/core/codec/output-descriptor-encoder.ts +101 -0
  101. package/src/core/codec/output-descriptors.ts +307 -0
  102. package/src/core/codec/types.ts +99 -36
  103. package/src/core/codec/well-known-inputs.ts +96 -0
  104. package/src/core/transport/agent-session.ts +330 -589
  105. package/src/core/transport/agent-view.ts +738 -0
  106. package/src/core/transport/client-session.ts +74 -69
  107. package/src/core/transport/decode-fold.ts +57 -47
  108. package/src/core/transport/headers.ts +57 -4
  109. package/src/core/transport/index.ts +2 -1
  110. package/src/core/transport/invocation.ts +1 -1
  111. package/src/core/transport/load-history-pages.ts +220 -0
  112. package/src/core/transport/load-history.ts +63 -61
  113. package/src/core/transport/pipe-stream.ts +10 -1
  114. package/src/core/transport/run-manager.ts +25 -31
  115. package/src/core/transport/session-support.ts +96 -0
  116. package/src/core/transport/tree.ts +414 -47
  117. package/src/core/transport/types/agent.ts +129 -102
  118. package/src/core/transport/types/client.ts +49 -13
  119. package/src/core/transport/types/tree.ts +61 -12
  120. package/src/core/transport/types/view.ts +57 -28
  121. package/src/core/transport/view.ts +520 -172
  122. package/src/core/transport/wire-log.ts +189 -0
  123. package/src/errors.ts +10 -3
  124. package/src/index.ts +44 -11
  125. package/src/react/contexts/client-session-context.ts +1 -1
  126. package/src/react/contexts/client-session-provider.tsx +38 -2
  127. package/src/react/index.ts +2 -1
  128. package/src/react/internal/skipped-session.ts +62 -0
  129. package/src/react/use-client-session.ts +7 -30
  130. package/src/react/use-view.ts +3 -3
  131. package/src/utils.ts +31 -97
  132. package/src/vercel/codec/decode-lifecycle.ts +70 -0
  133. package/src/vercel/codec/events.ts +1 -3
  134. package/src/vercel/codec/fields.ts +58 -0
  135. package/src/vercel/codec/fold-content.ts +54 -0
  136. package/src/vercel/codec/fold-data.ts +46 -0
  137. package/src/vercel/codec/fold-input.ts +255 -0
  138. package/src/vercel/codec/fold-lifecycle.ts +85 -0
  139. package/src/vercel/codec/fold-text.ts +55 -0
  140. package/src/vercel/codec/fold-tool-input.ts +86 -0
  141. package/src/vercel/codec/fold-tool-output.ts +79 -0
  142. package/src/vercel/codec/index.ts +23 -63
  143. package/src/vercel/codec/inputs.ts +116 -0
  144. package/src/vercel/codec/outputs.ts +207 -0
  145. package/src/vercel/codec/reducer-state.ts +169 -0
  146. package/src/vercel/codec/reducer.ts +52 -838
  147. package/src/vercel/codec/tool-transitions.ts +1 -12
  148. package/src/vercel/codec/wire-data.ts +64 -0
  149. package/src/vercel/index.ts +1 -0
  150. package/src/vercel/react/contexts/chat-transport-context.ts +1 -1
  151. package/src/vercel/react/use-chat-transport.ts +8 -28
  152. package/src/vercel/react/use-message-sync.ts +5 -10
  153. package/src/vercel/run-end-reason.ts +95 -16
  154. package/src/vercel/tool-part.ts +25 -0
  155. package/src/vercel/transport/chat-transport.ts +10 -22
  156. package/src/vercel/transport/index.ts +1 -1
  157. package/src/vercel/transport/run-output-stream.ts +7 -8
  158. package/src/version.ts +1 -1
  159. package/dist/core/transport/branch-chain.d.ts +0 -43
  160. package/dist/core/transport/load-conversation.d.ts +0 -128
  161. package/dist/vercel/codec/decoder.d.ts +0 -9
  162. package/dist/vercel/codec/encoder.d.ts +0 -11
  163. package/src/core/transport/branch-chain.ts +0 -58
  164. package/src/core/transport/load-conversation.ts +0 -355
  165. package/src/vercel/codec/decoder.ts +0 -696
  166. package/src/vercel/codec/encoder.ts +0 -548
@@ -17,13 +17,13 @@
17
17
  */
18
18
 
19
19
  import * as Ably from 'ably';
20
+ // Also augments RealtimeChannel with `.object` (ably/liveobjects side-effect).
21
+ import type * as AblyObjects from 'ably/liveobjects';
20
22
 
21
23
  import {
22
24
  EVENT_CANCEL,
23
25
  EVENT_RUN_END,
24
26
  HEADER_CODEC_MESSAGE_ID,
25
- HEADER_ERROR_CODE,
26
- HEADER_ERROR_MESSAGE,
27
27
  HEADER_EVENT_ID,
28
28
  HEADER_INPUT_CODEC_MESSAGE_ID,
29
29
  HEADER_INVOCATION_ID,
@@ -36,12 +36,14 @@ import { ErrorCode } from '../../errors.js';
36
36
  import { EventEmitter } from '../../event-emitter.js';
37
37
  import type { Logger } from '../../logger.js';
38
38
  import { LogLevel, makeLogger } from '../../logger.js';
39
- import { getTransportHeaders } from '../../utils.js';
39
+ import { errorCause, errorMessage, getTransportHeaders } from '../../utils.js';
40
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';
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';
44
45
  import { Invocation } from './invocation.js';
46
+ import { bestEffortDetach, continuityLostError, isContinuityLost, requireConnected } from './session-support.js';
45
47
  import type { DefaultTree } from './tree.js';
46
48
  import { createTree } from './tree.js';
47
49
  import type { ActiveRun, ClientSession, ClientSessionOptions, RunEndReason, SendOptions, Tree, View } from './types.js';
@@ -83,8 +85,8 @@ class DefaultClientSession<
83
85
  TMessage,
84
86
  > implements ClientSession<TInput, TOutput, TProjection, TMessage> {
85
87
  private readonly _channel: Ably.RealtimeChannel;
86
- private readonly _codec: ClientSessionOptions<TInput, TOutput, TProjection, TMessage>['codec'];
87
- private readonly _clientId: string | undefined;
88
+ private readonly _client: Ably.Realtime;
89
+ private readonly _codec: Codec<TInput, TOutput, TProjection, TMessage>;
88
90
  private readonly _logger: Logger;
89
91
 
90
92
  // Typed event emitter — the session emits only 'error'; all data events live on Tree/View
@@ -94,7 +96,13 @@ class DefaultClientSession<
94
96
  private readonly _tree: DefaultTree<TInput, TOutput, TProjection>;
95
97
  private readonly _view: DefaultView<TInput, TOutput, TProjection, TMessage>;
96
98
  private readonly _views = new Set<DefaultView<TInput, TOutput, TProjection, TMessage>>();
97
- private readonly _decoder: Decoder<TInput, TOutput>;
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;
98
106
  /**
99
107
  * Shared encoder for the lifetime of the session. The client only ever
100
108
  * uses `publishInput` (input wire), so the encoder's stream tracker map
@@ -137,10 +145,13 @@ class DefaultClientSession<
137
145
  // Spec: AIT-CT1a, AIT-CT1a2 — register this SDK on both the connection
138
146
  // (options.agents) and channel-attach (params.agent) paths. Idempotent
139
147
  // across sessions sharing one client.
140
- const channelOptions = registerAgent(options.client, options.codec);
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;
141
152
  this._channel = options.client.channels.get(options.channelName, channelOptions);
153
+ this._client = options.client;
142
154
  this._codec = options.codec;
143
- this._clientId = options.clientId;
144
155
  this._logger = (options.logger ?? makeLogger({ logLevel: LogLevel.Silent })).withContext({
145
156
  component: 'ClientSession',
146
157
  });
@@ -150,19 +161,17 @@ class DefaultClientSession<
150
161
 
151
162
  // Compose sub-components
152
163
  this._tree = createTree<TInput, TOutput, TProjection>(this._codec, this._logger);
164
+ this._applier = createWireApplier(this._tree, this._codec.createDecoder());
153
165
  this._view = createView<TInput, TOutput, TProjection, TMessage>({
154
166
  tree: this._tree,
155
167
  channel: this._channel,
156
168
  codec: this._codec,
169
+ applier: this._applier,
157
170
  sendDelegate: this._internalSend.bind(this),
158
171
  logger: this._logger,
159
172
  onClose: () => this._views.delete(this._view),
160
173
  });
161
- this._decoder = this._codec.createDecoder();
162
- this._encoder = this._codec.createEncoder(
163
- this._channel,
164
- this._clientId === undefined ? undefined : { clientId: this._clientId },
165
- );
174
+ this._encoder = this._codec.createEncoder(this._channel);
166
175
 
167
176
  this._views.add(this._view);
168
177
 
@@ -204,6 +213,20 @@ class DefaultClientSession<
204
213
  this._channel.on(this._onChannelStateChange);
205
214
  }
206
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
+
207
230
  // ---------------------------------------------------------------------------
208
231
  // Public connection API
209
232
  // ---------------------------------------------------------------------------
@@ -224,10 +247,10 @@ class DefaultClientSession<
224
247
  },
225
248
  (error: unknown) => {
226
249
  const errInfo = new Ably.ErrorInfo(
227
- `unable to subscribe to channel; ${error instanceof Error ? error.message : String(error)}`,
250
+ `unable to subscribe to channel; ${errorMessage(error)}`,
228
251
  ErrorCode.SessionSubscriptionError,
229
252
  500,
230
- error instanceof Ably.ErrorInfo ? error : undefined,
253
+ errorCause(error),
231
254
  );
232
255
  this._logger.error('DefaultClientSession.connect(); subscribe failed');
233
256
  this._emitter.emit('error', errInfo);
@@ -237,15 +260,24 @@ class DefaultClientSession<
237
260
  return this._connectPromise;
238
261
  }
239
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
+
240
279
  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;
280
+ return requireConnected(this._connectPromise, method);
249
281
  }
250
282
 
251
283
  // ---------------------------------------------------------------------------
@@ -267,24 +299,21 @@ class DefaultClientSession<
267
299
  // CAST: agent always writes a valid RunEndReason; default to 'complete' for robustness
268
300
  const reason = (headers[HEADER_RUN_REASON] ?? 'complete') as RunEndReason;
269
301
  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);
302
+ const errInfo = buildRunEndError(headers);
276
303
  this._logger.error('ClientSession._handleMessage(); agent error received', {
277
304
  runId: headers[HEADER_RUN_ID],
278
305
  invocationId: headers[HEADER_INVOCATION_ID],
279
- code,
306
+ code: errInfo.code,
280
307
  });
281
308
  this._emitter.emit('error', errInfo);
282
309
  }
283
310
  }
284
311
 
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);
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);
288
317
 
289
318
  // Live-only: resolve the pending `runId` promise on a fresh run-start or
290
319
  // a continuation run-resume. Key by the echoed `input-codec-message-id`
@@ -309,14 +338,13 @@ class DefaultClientSession<
309
338
  // 'update' events the apply triggers.
310
339
  this._tree.emitAblyMessage(ablyMessage);
311
340
  } catch (error) {
312
- const cause = error instanceof Ably.ErrorInfo ? error : undefined;
313
341
  this._emitter.emit(
314
342
  'error',
315
343
  new Ably.ErrorInfo(
316
- `unable to process channel message; ${error instanceof Error ? error.message : String(error)}`,
344
+ `unable to process channel message; ${errorMessage(error)}`,
317
345
  ErrorCode.SessionSubscriptionError,
318
346
  500,
319
- cause,
347
+ errorCause(error),
320
348
  ),
321
349
  );
322
350
  }
@@ -338,13 +366,7 @@ class DefaultClientSession<
338
366
  return;
339
367
  }
340
368
 
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;
369
+ if (!isContinuityLost(stateChange)) return;
348
370
 
349
371
  this._logger.error('ClientSession._handleChannelStateChange(); channel continuity lost', {
350
372
  current,
@@ -352,12 +374,7 @@ class DefaultClientSession<
352
374
  previous: stateChange.previous,
353
375
  });
354
376
 
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
- );
377
+ const err = continuityLostError(stateChange, 'deliver events');
361
378
 
362
379
  // Surface the loss via the session `error` event. Consumers that expose a
363
380
  // per-run stream (e.g. the Vercel ChatTransport) error their stream off
@@ -406,6 +423,7 @@ class DefaultClientSession<
406
423
  tree: this._tree,
407
424
  channel: this._channel,
408
425
  codec: this._codec,
426
+ applier: this._applier,
409
427
  sendDelegate: this._internalSend.bind(this),
410
428
  logger: this._logger,
411
429
  onClose: () => this._views.delete(view),
@@ -506,7 +524,7 @@ class DefaultClientSession<
506
524
  role: 'user',
507
525
  runId,
508
526
  codecMessageId,
509
- runClientId: this._clientId,
527
+ runClientId: this._resolveClientId(),
510
528
  ...(parent !== undefined && { parent }),
511
529
  ...(forkOf !== undefined && { forkOf }),
512
530
  ...(regenerates !== undefined && { regenerates }),
@@ -574,16 +592,15 @@ class DefaultClientSession<
574
592
  await this._encoder.publishInput(item.input, {
575
593
  extras: { headers: item.headers },
576
594
  messageId: item.codecMessageId,
577
- ...(this._clientId !== undefined && { clientId: this._clientId }),
578
595
  });
579
596
  }
580
597
  } catch (error) {
581
- const cause = error instanceof Ably.ErrorInfo ? error : undefined;
598
+ const cause = errorCause(error);
582
599
  const isPermission = cause?.statusCode === 401 || cause?.statusCode === 403;
583
600
  const err = new Ably.ErrorInfo(
584
601
  isPermission
585
602
  ? `unable to publish events; missing publish capability on the channel`
586
- : `unable to publish events; ${error instanceof Error ? error.message : String(error)}`,
603
+ : `unable to publish events; ${errorMessage(error)}`,
587
604
  isPermission ? ErrorCode.InsufficientCapability : ErrorCode.SessionSendFailed,
588
605
  isPermission ? 401 : 500,
589
606
  cause,
@@ -735,19 +752,7 @@ class DefaultClientSession<
735
752
  // Swallow: encoder close is best-effort during teardown
736
753
  }
737
754
 
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
- }
755
+ await bestEffortDetach(this._channel, this._connectPromise, this._logger, 'ClientSession');
751
756
  }
752
757
  }
753
758
 
@@ -5,87 +5,97 @@
5
5
  * the conversation Tree from the same raw Ably wire log. This module is the one
6
6
  * place that classifies a wire message (run-lifecycle vs codec-decoded), parses
7
7
  * or decodes it, and applies it to the Tree — so the two paths can never drift.
8
+ *
9
+ * The engine is exposed as a {@link WireApplier} binding one Tree to one
10
+ * decoder. A Tree has exactly one applier (the session constructs it and hands
11
+ * it to every View), so every route a wire message can arrive by — the live
12
+ * subscription, View history pagination, the agent's hydration walks — feeds
13
+ * the same decoder. The decoder's version-guarded stream trackers then make
14
+ * re-delivery across routes (an attach-boundary in-flight stream, a replayed
15
+ * history page) decode to nothing instead of double-folding. The delivery's
16
+ * `version.serial` is also threaded into the Tree, whose per-entry
17
+ * `decodedThrough` high-water-mark drops whole-wire replays that no decoder
18
+ * state can see (stateless discrete re-decodes).
8
19
  */
9
20
 
10
21
  import type * as Ably from 'ably';
11
22
 
12
23
  import { HEADER_RUN_ID } from '../../constants.js';
13
24
  import { getTransportHeaders } from '../../utils.js';
14
- import type { Codec, CodecInputEvent, CodecOutputEvent, Decoder } from '../codec/types.js';
25
+ import type { CodecInputEvent, CodecOutputEvent, Decoder } from '../codec/types.js';
15
26
  import { isRunLifecycleName, parseRunLifecycle } from './headers.js';
16
27
  import type { TreeInternal } from './tree.js';
17
28
  import type { RunLifecycleEvent } from './types.js';
18
29
 
19
30
  /**
20
- * Apply one inbound wire message to the tree.
21
- *
22
- * Run-lifecycle messages are turned into a {@link RunLifecycleEvent} via
23
- * {@link parseRunLifecycle} and applied with `applyRunLifecycle`; everything
24
- * else is decoded with `decoder` and applied with `applyMessage`, skipping
25
- * wire-only carriers that decode to no events and carry no run-id (the eventual
26
- * reply run is created later by its run-start).
27
- *
28
- * Does NOT emit the tree's `ably-message` event — the caller owns that, because
29
- * the live loop emits per message while history replay emits in a batch once
30
- * the whole page is applied. Returns the parsed lifecycle event so a live
31
- * caller can run its own side-effects (resolving a pending run-start,
32
- * surfacing an agent error); returns `undefined` for a codec-decoded message
33
- * or a lifecycle message that carried no run-id.
31
+ * The decode-and-apply engine for one Tree: a single codec decoder bound to a
32
+ * single Tree, shared by every route that feeds the Tree wire messages.
33
+ */
34
+ export interface WireApplier {
35
+ /**
36
+ * Apply one inbound wire message to the bound tree.
37
+ *
38
+ * Run-lifecycle messages are turned into a {@link RunLifecycleEvent} via
39
+ * {@link parseRunLifecycle} and applied with `applyRunLifecycle`; everything
40
+ * else is decoded with the bound decoder and applied with `applyMessage`,
41
+ * skipping wire-only carriers that decode to no events and carry no run-id
42
+ * (the eventual reply run is created later by its run-start).
43
+ *
44
+ * Does NOT emit the tree's `ably-message` event the caller owns that,
45
+ * because the live loop emits per message while history replay emits in a
46
+ * batch once the whole page is applied. Returns the parsed lifecycle event
47
+ * so a live caller can run its own side-effects (resolving a pending
48
+ * run-start, surfacing an agent error); returns `undefined` for a
49
+ * codec-decoded message or a lifecycle message that carried no run-id.
50
+ * @param rawMsg - The inbound Ably wire message.
51
+ * @returns The parsed run-lifecycle event, or `undefined`.
52
+ */
53
+ apply(rawMsg: Ably.InboundMessage): RunLifecycleEvent | undefined;
54
+ }
55
+
56
+ /**
57
+ * Classify, decode, and apply one inbound wire message to the tree. See
58
+ * {@link WireApplier.apply} for the contract.
34
59
  * @param tree - The tree to apply the message to.
35
60
  * @param decoder - The codec decoder used for non-lifecycle messages.
36
61
  * @param rawMsg - The inbound Ably wire message.
37
62
  * @returns The parsed run-lifecycle event, or `undefined`.
38
63
  */
39
- export const applyWireMessage = <TInput extends CodecInputEvent, TOutput extends CodecOutputEvent, TProjection>(
64
+ const applyWireMessage = <TInput extends CodecInputEvent, TOutput extends CodecOutputEvent, TProjection>(
40
65
  tree: TreeInternal<TInput, TOutput, TProjection>,
41
66
  decoder: Decoder<TInput, TOutput>,
42
67
  rawMsg: Ably.InboundMessage,
43
68
  ): RunLifecycleEvent | undefined => {
44
69
  const headers = getTransportHeaders(rawMsg);
45
70
  const serial = rawMsg.serial;
71
+ // Top-level timestamp — the message's create time on every delivery (an
72
+ // append's own receive time lives in `version.timestamp`). The retention
73
+ // clock is sound on this timeline because run-end, a fresh create published
74
+ // after every wire of its run, bounds the node's last activity.
75
+ const timestamp = rawMsg.timestamp;
46
76
 
47
77
  if (isRunLifecycleName(rawMsg.name)) {
48
- const event = parseRunLifecycle(rawMsg.name, headers, serial);
78
+ const event = parseRunLifecycle(rawMsg.name, headers, serial, timestamp);
49
79
  if (event) tree.applyRunLifecycle(event);
50
80
  return event;
51
81
  }
52
82
 
53
83
  const { inputs, outputs } = decoder.decode(rawMsg);
54
84
  if (inputs.length > 0 || outputs.length > 0 || headers[HEADER_RUN_ID]) {
55
- tree.applyMessage({ inputs, outputs }, headers, serial);
85
+ tree.applyMessage({ inputs, outputs }, headers, serial, timestamp, rawMsg.version.serial);
56
86
  }
57
87
  return undefined;
58
88
  };
59
89
 
60
90
  /**
61
- * Decode one wire message with `decoder` and fold its events into `projection`,
62
- * returning the updated projection. Unlike {@link applyWireMessage} this builds
63
- * a standalone projection rather than applying to a tree used by the agent's
64
- * conversation reconstruction. The caller owns the decoder so its streaming
65
- * state can span the messages of a run, and chooses the reducer routing key.
66
- * @param codec - The codec whose inherited Reducer `fold` method folds each decoded event into the projection.
67
- * @param decoder - The caller-owned codec decoder (reused across a run's wires).
68
- * @param projection - The projection to fold the message's events into.
69
- * @param rawMsg - The wire message to decode and fold.
70
- * @param messageId - The reducer routing key (codec-message-id) for this message.
71
- * @returns The projection after folding all of the message's decoded events.
91
+ * Bind a Tree and a decoder into the Tree's single {@link WireApplier}.
92
+ * @param tree - The tree the applier feeds.
93
+ * @param decoder - The codec decoder shared by every route into the tree.
94
+ * @returns The applier.
72
95
  */
73
- export const foldMessageInto = <
74
- TInput extends CodecInputEvent,
75
- TOutput extends CodecOutputEvent,
76
- TProjection,
77
- TMessage,
78
- >(
79
- codec: Codec<TInput, TOutput, TProjection, TMessage>,
96
+ export const createWireApplier = <TInput extends CodecInputEvent, TOutput extends CodecOutputEvent, TProjection>(
97
+ tree: TreeInternal<TInput, TOutput, TProjection>,
80
98
  decoder: Decoder<TInput, TOutput>,
81
- projection: TProjection,
82
- rawMsg: Ably.InboundMessage,
83
- messageId: string,
84
- ): TProjection => {
85
- const { inputs, outputs } = decoder.decode(rawMsg);
86
- let next = projection;
87
- for (const event of [...inputs, ...outputs]) {
88
- next = codec.fold(next, event, { serial: rawMsg.serial ?? '', messageId });
89
- }
90
- return next;
91
- };
99
+ ): WireApplier => ({
100
+ apply: (rawMsg: Ably.InboundMessage): RunLifecycleEvent | undefined => applyWireMessage(tree, decoder, rawMsg),
101
+ });
@@ -2,16 +2,20 @@
2
2
  * Transport header builder.
3
3
  *
4
4
  * Single source of truth for which transport headers every transport
5
- * message carries. Used by the agent session (pipe, addEvents) and by
5
+ * message carries. Used by the agent session (pipe) and by
6
6
  * the client session (optimistic message stamping).
7
7
  */
8
8
 
9
+ import * as Ably from 'ably';
10
+
9
11
  import {
10
12
  EVENT_RUN_END,
11
13
  EVENT_RUN_RESUME,
12
14
  EVENT_RUN_START,
13
15
  EVENT_RUN_SUSPEND,
14
16
  HEADER_CODEC_MESSAGE_ID,
17
+ HEADER_ERROR_CODE,
18
+ HEADER_ERROR_MESSAGE,
15
19
  HEADER_EVENT_ID,
16
20
  HEADER_FORK_OF,
17
21
  HEADER_INPUT_CLIENT_ID,
@@ -24,6 +28,7 @@ import {
24
28
  HEADER_RUN_ID,
25
29
  HEADER_RUN_REASON,
26
30
  } from '../../constants.js';
31
+ import { ErrorCode } from '../../errors.js';
27
32
  import type { RunEndReason, RunLifecycleEvent } from './types.js';
28
33
 
29
34
  /**
@@ -105,6 +110,11 @@ export const buildTransportHeaders = (opts: {
105
110
  * @param opts.inputClientId - ClientId of the triggering input event.
106
111
  * @param opts.inputCodecMessageId - Codec-message-id of the triggering input event.
107
112
  * @param opts.reason - Terminal reason; stamped on run-end only.
113
+ * @param opts.errorCode - Numeric error code stamped as `error-code` on
114
+ * run-end. Set only when the run ended in error and the agent supplied an
115
+ * error to surface; gives codec-agnostic consumers a baseline failure detail.
116
+ * @param opts.errorMessage - Error message stamped as `error-message` on
117
+ * run-end. Paired with `errorCode`; set under the same condition.
108
118
  * @returns A headers record with the lifecycle headers set.
109
119
  */
110
120
  export const buildLifecycleHeaders = (opts: {
@@ -117,6 +127,8 @@ export const buildLifecycleHeaders = (opts: {
117
127
  inputClientId?: string;
118
128
  inputCodecMessageId?: string;
119
129
  reason?: RunEndReason;
130
+ errorCode?: number;
131
+ errorMessage?: string;
120
132
  }): Record<string, string> => {
121
133
  const h: Record<string, string> = {
122
134
  [HEADER_RUN_ID]: opts.runId,
@@ -129,6 +141,8 @@ export const buildLifecycleHeaders = (opts: {
129
141
  if (opts.invocationId !== undefined) h[HEADER_INVOCATION_ID] = opts.invocationId;
130
142
  if (opts.inputClientId !== undefined) h[HEADER_INPUT_CLIENT_ID] = opts.inputClientId;
131
143
  if (opts.inputCodecMessageId !== undefined) h[HEADER_INPUT_CODEC_MESSAGE_ID] = opts.inputCodecMessageId;
144
+ if (opts.errorCode !== undefined) h[HEADER_ERROR_CODE] = String(opts.errorCode);
145
+ if (opts.errorMessage !== undefined) h[HEADER_ERROR_MESSAGE] = opts.errorMessage;
132
146
  return h;
133
147
  };
134
148
 
@@ -151,6 +165,26 @@ type RunLifecycleName =
151
165
  export const isRunLifecycleName = (name: string | undefined): name is RunLifecycleName =>
152
166
  name === EVENT_RUN_START || name === EVENT_RUN_SUSPEND || name === EVENT_RUN_RESUME || name === EVENT_RUN_END;
153
167
 
168
+ /**
169
+ * Reconstruct the terminal `Ably.ErrorInfo` for a run that ended in error, from
170
+ * its run-end transport headers. Reads the `error-code` / `error-message`
171
+ * headers the agent stamps (see {@link buildLifecycleHeaders}); falls back to a
172
+ * generic code/message when a run ended in error without detail. Single source
173
+ * of truth for the header→ErrorInfo derivation, shared by the client session's
174
+ * `on('error')` emit and the Tree's `RunInfo.error`.
175
+ * @param headers - Transport headers from the inbound run-end message.
176
+ * @returns The reconstructed terminal error.
177
+ */
178
+ export const buildRunEndError = (headers: Record<string, string>): Ably.ErrorInfo => {
179
+ const codeRaw = headers[HEADER_ERROR_CODE];
180
+ const parsedCode = codeRaw === undefined ? Number.NaN : Number(codeRaw);
181
+ const code = Number.isFinite(parsedCode) ? parsedCode : ErrorCode.SessionSubscriptionError;
182
+ const message = headers[HEADER_ERROR_MESSAGE] ?? 'agent reported an error';
183
+ // 5-digit codes encode their HTTP status in the leading 3 digits; otherwise 500.
184
+ const statusCode = code >= 10000 && code < 60000 ? Math.floor(code / 100) : 500;
185
+ return new Ably.ErrorInfo(message, code, statusCode);
186
+ };
187
+
154
188
  /**
155
189
  * Parse an inbound run-lifecycle Ably message into a {@link RunLifecycleEvent}.
156
190
  *
@@ -162,6 +196,9 @@ export const isRunLifecycleName = (name: string | undefined): name is RunLifecyc
162
196
  * @param headers - Transport headers from the inbound Ably message.
163
197
  * @param serial - Ably channel serial of the message, or `undefined` for an
164
198
  * optimistic local event. Stamped onto the returned event.
199
+ * @param timestamp - Ably server timestamp (epoch ms) of the message, or
200
+ * `undefined` for an optimistic local event. Stamped onto the returned
201
+ * event; drives the Tree's event-log retention clock.
165
202
  * @returns The lifecycle event, or `undefined` when `name` is not a
166
203
  * run-lifecycle event name or the message carries no `run-id`.
167
204
  */
@@ -169,11 +206,13 @@ export const parseRunLifecycle = (
169
206
  name: string,
170
207
  headers: Record<string, string>,
171
208
  serial: string | undefined,
209
+ timestamp: number | undefined,
172
210
  ): RunLifecycleEvent | undefined => {
173
211
  const runId = headers[HEADER_RUN_ID];
174
212
  if (!runId) return undefined;
175
213
 
176
214
  const clientId = headers[HEADER_RUN_CLIENT_ID] ?? '';
215
+ const stamped = timestamp === undefined ? {} : { timestamp };
177
216
 
178
217
  if (name === EVENT_RUN_START) {
179
218
  const parent = headers[HEADER_PARENT];
@@ -185,6 +224,7 @@ export const parseRunLifecycle = (
185
224
  clientId,
186
225
  serial,
187
226
  invocationId: headers[HEADER_INVOCATION_ID] ?? '',
227
+ ...stamped,
188
228
  ...(parent !== undefined && { parent }),
189
229
  ...(forkOf !== undefined && { forkOf }),
190
230
  ...(regenerates !== undefined && { regenerates }),
@@ -192,17 +232,30 @@ export const parseRunLifecycle = (
192
232
  }
193
233
 
194
234
  if (name === EVENT_RUN_SUSPEND) {
195
- return { type: 'suspend', runId, clientId, serial, invocationId: headers[HEADER_INVOCATION_ID] ?? '' };
235
+ return { type: 'suspend', runId, clientId, serial, invocationId: headers[HEADER_INVOCATION_ID] ?? '', ...stamped };
196
236
  }
197
237
 
198
238
  if (name === EVENT_RUN_RESUME) {
199
- return { type: 'resume', runId, clientId, serial, invocationId: headers[HEADER_INVOCATION_ID] ?? '' };
239
+ return { type: 'resume', runId, clientId, serial, invocationId: headers[HEADER_INVOCATION_ID] ?? '', ...stamped };
200
240
  }
201
241
 
202
242
  if (name === EVENT_RUN_END) {
203
243
  // CAST: agent always writes a valid RunEndReason; default to 'complete' for robustness.
204
244
  const reason = (headers[HEADER_RUN_REASON] ?? 'complete') as RunEndReason;
205
- return { type: 'end', runId, clientId, serial, invocationId: headers[HEADER_INVOCATION_ID] ?? '', reason };
245
+ const invocationId = headers[HEADER_INVOCATION_ID] ?? '';
246
+ if (reason === 'error') {
247
+ return {
248
+ type: 'end',
249
+ runId,
250
+ clientId,
251
+ serial,
252
+ invocationId,
253
+ reason,
254
+ ...stamped,
255
+ error: buildRunEndError(headers),
256
+ };
257
+ }
258
+ return { type: 'end', runId, clientId, serial, invocationId, reason, ...stamped };
206
259
  }
207
260
 
208
261
  return undefined;
@@ -8,17 +8,18 @@ export type {
8
8
  ClientSession,
9
9
  ClientSessionOptions,
10
10
  ConversationNode,
11
- EventsNode,
12
11
  InputNode,
13
12
  LoadConversationOptions,
14
13
  MessageNode,
15
14
  OutputEvent,
16
15
  PipeOptions,
17
16
  Run,
17
+ RunEndParams,
18
18
  RunEndReason,
19
19
  RunInfo,
20
20
  RunLifecycleEvent,
21
21
  RunNode,
22
+ RunNodeState,
22
23
  RunRuntime,
23
24
  RunView,
24
25
  SendOptions,
@@ -11,7 +11,7 @@
11
11
  * const invocation = Invocation.fromJSON(data);
12
12
  * const run = session.createRun(invocation, { signal: req.signal });
13
13
  * await run.start();
14
- * await run.loadProjection(); // fetch run projection from the channel
14
+ * await run.loadConversation(); // walk channel history into the session tree
15
15
  * const messages = run.messages;
16
16
  * ```
17
17
  *