@ably/ai-transport 0.0.1 → 0.1.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 (110) hide show
  1. package/README.md +54 -47
  2. package/dist/ably-ai-transport.js +1006 -539
  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 +4 -0
  7. package/dist/core/codec/types.d.ts +19 -2
  8. package/dist/core/transport/decode-history.d.ts +8 -6
  9. package/dist/core/transport/headers.d.ts +4 -2
  10. package/dist/core/transport/index.d.ts +4 -1
  11. package/dist/core/transport/pipe-stream.d.ts +3 -2
  12. package/dist/core/transport/stream-router.d.ts +11 -1
  13. package/dist/core/transport/tree.d.ts +171 -0
  14. package/dist/core/transport/turn-manager.d.ts +4 -1
  15. package/dist/core/transport/types.d.ts +270 -119
  16. package/dist/core/transport/view.d.ts +166 -0
  17. package/dist/errors.d.ts +19 -2
  18. package/dist/index.d.ts +3 -1
  19. package/dist/react/ably-ai-transport-react.js +1019 -486
  20. package/dist/react/ably-ai-transport-react.js.map +1 -1
  21. package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
  22. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
  23. package/dist/react/contexts/transport-context.d.ts +31 -0
  24. package/dist/react/contexts/transport-provider.d.ts +49 -0
  25. package/dist/react/create-transport-hooks.d.ts +124 -0
  26. package/dist/react/index.d.ts +14 -8
  27. package/dist/react/use-ably-messages.d.ts +14 -8
  28. package/dist/react/use-active-turns.d.ts +7 -3
  29. package/dist/react/use-client-transport.d.ts +78 -5
  30. package/dist/react/use-create-view.d.ts +22 -0
  31. package/dist/react/use-tree.d.ts +20 -0
  32. package/dist/react/use-view.d.ts +79 -0
  33. package/dist/vercel/ably-ai-transport-vercel.js +1478 -842
  34. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
  35. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
  36. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
  37. package/dist/vercel/codec/tool-transitions.d.ts +50 -0
  38. package/dist/vercel/index.d.ts +3 -0
  39. package/dist/vercel/react/ably-ai-transport-vercel-react.js +9099 -852
  40. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
  41. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +45 -1
  42. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
  43. package/dist/vercel/react/contexts/chat-transport-context.d.ts +32 -0
  44. package/dist/vercel/react/contexts/chat-transport-provider.d.ts +84 -0
  45. package/dist/vercel/react/index.d.ts +5 -0
  46. package/dist/vercel/react/use-chat-transport.d.ts +61 -20
  47. package/dist/vercel/react/use-message-sync.d.ts +41 -9
  48. package/dist/vercel/react/use-staged-add-tool-approval-response.d.ts +30 -0
  49. package/dist/vercel/tool-approvals.d.ts +124 -0
  50. package/dist/vercel/tool-events.d.ts +26 -0
  51. package/dist/vercel/transport/chat-transport.d.ts +33 -11
  52. package/dist/vercel/transport/index.d.ts +5 -2
  53. package/package.json +23 -17
  54. package/src/constants.ts +6 -0
  55. package/src/core/codec/encoder.ts +10 -1
  56. package/src/core/codec/types.ts +19 -3
  57. package/src/core/transport/client-transport.ts +382 -364
  58. package/src/core/transport/decode-history.ts +229 -81
  59. package/src/core/transport/headers.ts +6 -2
  60. package/src/core/transport/index.ts +13 -5
  61. package/src/core/transport/pipe-stream.ts +8 -5
  62. package/src/core/transport/server-transport.ts +212 -58
  63. package/src/core/transport/stream-router.ts +21 -3
  64. package/src/core/transport/{conversation-tree.ts → tree.ts} +192 -77
  65. package/src/core/transport/turn-manager.ts +28 -10
  66. package/src/core/transport/types.ts +318 -139
  67. package/src/core/transport/view.ts +840 -0
  68. package/src/errors.ts +21 -1
  69. package/src/index.ts +10 -5
  70. package/src/react/contexts/transport-context.ts +37 -0
  71. package/src/react/contexts/transport-provider.tsx +164 -0
  72. package/src/react/create-transport-hooks.ts +144 -0
  73. package/src/react/index.ts +15 -8
  74. package/src/react/use-ably-messages.ts +34 -16
  75. package/src/react/use-active-turns.ts +28 -17
  76. package/src/react/use-client-transport.ts +184 -24
  77. package/src/react/use-create-view.ts +68 -0
  78. package/src/react/use-tree.ts +53 -0
  79. package/src/react/use-view.ts +233 -0
  80. package/src/react/vite.config.ts +4 -1
  81. package/src/vercel/codec/accumulator.ts +64 -79
  82. package/src/vercel/codec/decoder.ts +11 -8
  83. package/src/vercel/codec/encoder.ts +68 -54
  84. package/src/vercel/codec/index.ts +0 -2
  85. package/src/vercel/codec/tool-transitions.ts +122 -0
  86. package/src/vercel/index.ts +17 -0
  87. package/src/vercel/react/contexts/chat-transport-context.ts +40 -0
  88. package/src/vercel/react/contexts/chat-transport-provider.tsx +122 -0
  89. package/src/vercel/react/index.ts +14 -0
  90. package/src/vercel/react/use-chat-transport.ts +164 -42
  91. package/src/vercel/react/use-message-sync.ts +77 -19
  92. package/src/vercel/react/use-staged-add-tool-approval-response.ts +87 -0
  93. package/src/vercel/react/vite.config.ts +4 -2
  94. package/src/vercel/tool-approvals.ts +380 -0
  95. package/src/vercel/tool-events.ts +53 -0
  96. package/src/vercel/transport/chat-transport.ts +225 -79
  97. package/src/vercel/transport/index.ts +14 -3
  98. package/dist/core/transport/conversation-tree.d.ts +0 -9
  99. package/dist/react/use-conversation-tree.d.ts +0 -20
  100. package/dist/react/use-edit.d.ts +0 -7
  101. package/dist/react/use-history.d.ts +0 -19
  102. package/dist/react/use-messages.d.ts +0 -7
  103. package/dist/react/use-regenerate.d.ts +0 -7
  104. package/dist/react/use-send.d.ts +0 -7
  105. package/src/react/use-conversation-tree.ts +0 -71
  106. package/src/react/use-edit.ts +0 -24
  107. package/src/react/use-history.ts +0 -111
  108. package/src/react/use-messages.ts +0 -32
  109. package/src/react/use-regenerate.ts +0 -24
  110. package/src/react/use-send.ts +0 -25
@@ -18,7 +18,6 @@ import {
18
18
  HEADER_CANCEL_CLIENT_ID,
19
19
  HEADER_CANCEL_OWN,
20
20
  HEADER_CANCEL_TURN_ID,
21
- HEADER_MSG_ID,
22
21
  } from '../../constants.js';
23
22
  import { ErrorCode } from '../../errors.js';
24
23
  import type { Logger } from '../../logger.js';
@@ -32,7 +31,8 @@ import type {
32
31
  AddMessagesResult,
33
32
  CancelFilter,
34
33
  CancelRequest,
35
- MessageWithHeaders,
34
+ EventsNode,
35
+ MessageNode,
36
36
  NewTurnOptions,
37
37
  ServerTransport,
38
38
  ServerTransportOptions,
@@ -50,10 +50,27 @@ interface RegisteredTurn {
50
50
  turnId: string;
51
51
  clientId: string;
52
52
  controller: AbortController;
53
+ /** Composite signal that fires when either the internal controller or the external signal aborts. */
54
+ signal: AbortSignal;
53
55
  onCancel?: (request: CancelRequest) => Promise<boolean>;
54
56
  onError?: (error: Ably.ErrorInfo) => void;
55
57
  }
56
58
 
59
+ // ---------------------------------------------------------------------------
60
+ // Internal state machines
61
+ // ---------------------------------------------------------------------------
62
+
63
+ enum ServerTransportState {
64
+ READY = 'ready',
65
+ CLOSED = 'closed',
66
+ }
67
+
68
+ enum TurnState {
69
+ INITIALIZED = 'initialized',
70
+ STARTED = 'started',
71
+ ENDED = 'ended',
72
+ }
73
+
57
74
  // ---------------------------------------------------------------------------
58
75
  // Implementation
59
76
  // ---------------------------------------------------------------------------
@@ -69,6 +86,10 @@ class DefaultServerTransport<TEvent, TMessage> implements ServerTransport<TEvent
69
86
  private readonly _channelListener: (msg: Ably.InboundMessage) => void;
70
87
  private readonly _attachPromise: Promise<void>;
71
88
 
89
+ private _state = ServerTransportState.READY;
90
+ private _hasAttachedOnce: boolean;
91
+ private readonly _onChannelStateChange: Ably.channelEventCallback;
92
+
72
93
  constructor(options: ServerTransportOptions<TEvent, TMessage>) {
73
94
  this._channel = options.channel;
74
95
  this._codec = options.codec;
@@ -98,6 +119,19 @@ class DefaultServerTransport<TEvent, TMessage> implements ServerTransport<TEvent
98
119
  },
99
120
  );
100
121
 
122
+ // Spec: AIT-ST12, AIT-ST12a
123
+ // Listen for channel state changes that break message continuity. The
124
+ // server only consumes cancel messages from the channel, so losing one
125
+ // is survivable — but the developer needs to know so they can decide
126
+ // whether to abort in-flight work. _hasAttachedOnce is seeded from the
127
+ // channel's current state so pre-attached channels are handled correctly;
128
+ // it distinguishes the initial attach from a genuine discontinuity.
129
+ this._hasAttachedOnce = this._channel.state === 'attached';
130
+ this._onChannelStateChange = (stateChange: Ably.ChannelStateChange) => {
131
+ this._handleChannelStateChange(stateChange);
132
+ };
133
+ this._channel.on(this._onChannelStateChange);
134
+
101
135
  this._logger?.debug('DefaultServerTransport(); transport created');
102
136
  }
103
137
 
@@ -113,8 +147,11 @@ class DefaultServerTransport<TEvent, TMessage> implements ServerTransport<TEvent
113
147
 
114
148
  // Spec: AIT-ST11
115
149
  close(): void {
150
+ if (this._state === ServerTransportState.CLOSED) return;
151
+ this._state = ServerTransportState.CLOSED;
116
152
  this._logger?.trace('DefaultServerTransport.close();');
117
153
  this._channel.unsubscribe(EVENT_CANCEL, this._channelListener);
154
+ this._channel.off(this._onChannelStateChange);
118
155
  for (const reg of this._registeredTurns.values()) {
119
156
  reg.controller.abort();
120
157
  }
@@ -143,10 +180,11 @@ class DefaultServerTransport<TEvent, TMessage> implements ServerTransport<TEvent
143
180
  return [];
144
181
  }
145
182
 
146
- // Spec: AIT-ST8, AIT-ST9
183
+ // Spec: AIT-ST8, AIT-ST8a, AIT-ST8b, AIT-ST8c, AIT-ST8d, AIT-ST9, AIT-ST9a
147
184
  private async _handleCancelMessage(msg: Ably.InboundMessage): Promise<void> {
148
185
  const headers = getHeaders(msg);
149
186
 
187
+ // Spec: AIT-ST8a, AIT-ST8b, AIT-ST8c, AIT-ST8d
150
188
  const filter: CancelFilter = {};
151
189
  if (headers[HEADER_CANCEL_TURN_ID]) {
152
190
  filter.turnId = headers[HEADER_CANCEL_TURN_ID];
@@ -203,6 +241,50 @@ class DefaultServerTransport<TEvent, TMessage> implements ServerTransport<TEvent
203
241
  }
204
242
  }
205
243
 
244
+ // -------------------------------------------------------------------------
245
+ // Channel state change handler
246
+ // -------------------------------------------------------------------------
247
+
248
+ // Spec: AIT-ST12, AIT-ST12a
249
+ private _handleChannelStateChange(stateChange: Ably.ChannelStateChange): void {
250
+ if (this._state === ServerTransportState.CLOSED) return;
251
+
252
+ const { current, resumed } = stateChange;
253
+
254
+ // Track the initial attach so we don't treat it as a discontinuity
255
+ if (current === 'attached' && !this._hasAttachedOnce) {
256
+ this._hasAttachedOnce = true;
257
+ return;
258
+ }
259
+
260
+ // Continuity-breaking states:
261
+ // - FAILED, SUSPENDED, DETACHED: no more messages expected (or gap)
262
+ // - ATTACHED with resumed: false (UPDATE): messages were lost
263
+ const continuityLost =
264
+ current === 'failed' || current === 'suspended' || current === 'detached' || (current === 'attached' && !resumed);
265
+
266
+ if (!continuityLost) return;
267
+
268
+ this._logger?.error('DefaultServerTransport._handleChannelStateChange(); channel continuity lost', {
269
+ current,
270
+ resumed,
271
+ previous: stateChange.previous,
272
+ });
273
+
274
+ const err = new Ably.ErrorInfo(
275
+ `unable to deliver cancel messages; channel continuity lost (${current}${current === 'attached' ? ', resumed: false' : ''})`,
276
+ ErrorCode.ChannelContinuityLost,
277
+ 500,
278
+ stateChange.reason,
279
+ );
280
+
281
+ // Transport-level notification only: continuity loss is not scoped to any
282
+ // turn. Per-turn onError handlers are reserved for errors from that turn's
283
+ // own operations (publish failures, encoder errors). Developers that need
284
+ // per-turn reaction can iterate active turns from the transport handler.
285
+ this._onError?.(err);
286
+ }
287
+
206
288
  // -------------------------------------------------------------------------
207
289
  // Channel subscription handler
208
290
  // -------------------------------------------------------------------------
@@ -248,17 +330,23 @@ class DefaultServerTransport<TEvent, TMessage> implements ServerTransport<TEvent
248
330
  onError: turnOnError,
249
331
  parent: turnParent,
250
332
  forkOf: turnForkOf,
333
+ signal: externalSignal,
251
334
  } = turnOpts;
252
335
 
253
336
  const controller = new AbortController();
254
- let started = false;
255
- let ended = false;
337
+ let state = TurnState.INITIALIZED;
338
+
339
+ // Compose the internal controller signal with the external signal (e.g.
340
+ // req.signal) so platform-level cancellation (request cancellation, function
341
+ // timeout) aborts the turn through the same path as Ably cancel messages.
342
+ const signal = externalSignal ? AbortSignal.any([controller.signal, externalSignal]) : controller.signal;
256
343
 
257
- // Register immediately so early cancels can fire the abort signal.
344
+ // Spec: AIT-ST3a — register immediately so early cancels can fire the abort signal.
258
345
  const registration: RegisteredTurn = {
259
346
  turnId,
260
347
  clientId: turnClientId ?? '',
261
348
  controller,
349
+ signal,
262
350
  onCancel,
263
351
  onError: turnOnError,
264
352
  };
@@ -278,25 +366,29 @@ class DefaultServerTransport<TEvent, TMessage> implements ServerTransport<TEvent
278
366
  return turnId;
279
367
  },
280
368
  get abortSignal() {
281
- return controller.signal;
369
+ return signal;
282
370
  },
283
371
 
284
- // Spec: AIT-ST4
372
+ // Spec: AIT-ST4, AIT-ST4a, AIT-ST4b
285
373
  start: async (): Promise<void> => {
286
374
  logger?.trace('Turn.start();', { turnId });
287
375
 
288
- if (controller.signal.aborted) {
376
+ // Spec: AIT-ST4a
377
+ if (signal.aborted) {
289
378
  throw new Ably.ErrorInfo(
290
379
  `unable to start turn; turn ${turnId} was cancelled before start()`,
291
380
  ErrorCode.InvalidArgument,
292
381
  400,
293
382
  );
294
383
  }
295
- if (started) return;
296
- started = true;
384
+ if (state !== TurnState.INITIALIZED) return;
385
+ state = TurnState.STARTED;
297
386
 
298
387
  try {
299
- await turnManager.startTurn(turnId, turnClientId, controller);
388
+ await turnManager.startTurn(turnId, turnClientId, controller, {
389
+ parent: turnParent,
390
+ forkOf: turnForkOf,
391
+ });
300
392
  } catch (error) {
301
393
  const errInfo = new Ably.ErrorInfo(
302
394
  `unable to publish turn-start for turn ${turnId}; ${error instanceof Error ? error.message : String(error)}`,
@@ -305,21 +397,17 @@ class DefaultServerTransport<TEvent, TMessage> implements ServerTransport<TEvent
305
397
  error instanceof Ably.ErrorInfo ? error : undefined,
306
398
  );
307
399
  logger?.error('Turn.start(); failed to publish turn-start', { turnId });
308
- turnOnError?.(errInfo);
309
400
  throw errInfo;
310
401
  }
311
402
 
312
403
  logger?.debug('Turn.start(); turn started', { turnId });
313
404
  },
314
405
 
315
- // Spec: AIT-ST5
316
- addMessages: async (
317
- inputs: MessageWithHeaders<TMessage>[],
318
- opts?: AddMessageOptions,
319
- ): Promise<AddMessagesResult> => {
320
- logger?.trace('Turn.addMessages();', { turnId, count: inputs.length });
406
+ // Spec: AIT-ST5, AIT-ST5a, AIT-ST5b, AIT-ST5c
407
+ addMessages: async (nodes: MessageNode<TMessage>[], opts?: AddMessageOptions): Promise<AddMessagesResult> => {
408
+ logger?.trace('Turn.addMessages();', { turnId, count: nodes.length });
321
409
 
322
- if (!started) {
410
+ if (state === TurnState.INITIALIZED) {
323
411
  throw new Ably.ErrorInfo(
324
412
  `unable to add messages; start() must be called before addMessages() (turn ${turnId})`,
325
413
  ErrorCode.InvalidArgument,
@@ -330,48 +418,104 @@ class DefaultServerTransport<TEvent, TMessage> implements ServerTransport<TEvent
330
418
 
331
419
  const msgIds: string[] = [];
332
420
 
333
- for (const input of inputs) {
334
- const msgId = crypto.randomUUID();
421
+ try {
422
+ for (const node of nodes) {
423
+ // Build transport headers from the node's typed fields, then merge
424
+ // any extra headers from the node (e.g. domain-specific headers).
425
+ const headers = mergeHeaders(
426
+ buildTransportHeaders({
427
+ role: 'user',
428
+ turnId,
429
+ msgId: node.msgId,
430
+ turnClientId: opts?.clientId,
431
+ parent: node.parentId ?? turnParent,
432
+ forkOf: node.forkOf ?? turnForkOf,
433
+ }),
434
+ node.headers,
435
+ );
436
+
437
+ const encoder = codec.createEncoder(channel, {
438
+ extras: { headers },
439
+ onMessage,
440
+ });
335
441
 
336
- // Transport headers are the defaults; per-message headers from the
337
- // client override them. This lets the client's x-ably-msg-id pass
338
- // through for optimistic reconciliation with client inserts.
339
- const headers = mergeHeaders(
340
- buildTransportHeaders({
341
- role: 'user',
342
- turnId,
343
- msgId,
344
- turnClientId: opts?.clientId,
345
- // Per-operation options override turn-level defaults
346
- parent: opts?.parent === undefined ? (turnParent ?? undefined) : (opts.parent ?? undefined),
347
- forkOf: opts?.forkOf ?? turnForkOf,
348
- }),
349
- input.headers,
442
+ await encoder.writeMessages([node.message], opts?.clientId ? { clientId: opts.clientId } : undefined);
443
+
444
+ msgIds.push(node.msgId);
445
+ }
446
+ } catch (error) {
447
+ const errInfo = new Ably.ErrorInfo(
448
+ `unable to publish messages for turn ${turnId}; ${error instanceof Error ? error.message : String(error)}`,
449
+ ErrorCode.TurnLifecycleError,
450
+ 500,
451
+ error instanceof Ably.ErrorInfo ? error : undefined,
350
452
  );
453
+ logger?.error('Turn.addMessages(); publish failed', { turnId });
454
+ throw errInfo;
455
+ }
351
456
 
352
- const encoder = codec.createEncoder(channel, {
353
- extras: { headers },
354
- onMessage,
355
- });
457
+ logger?.debug('Turn.addMessages(); messages published', { turnId, count: nodes.length });
458
+ return { msgIds };
459
+ },
356
460
 
357
- await encoder.writeMessages([input.message], opts?.clientId ? { clientId: opts.clientId } : undefined);
461
+ // Spec: AIT-ST5c
462
+ addEvents: async (nodes: EventsNode<TEvent>[]): Promise<void> => {
463
+ logger?.trace('Turn.addEvents();', { turnId, count: nodes.length });
358
464
 
359
- // Capture the effective msg-id after input.headers may have overridden it.
360
- msgIds.push(headers[HEADER_MSG_ID] ?? msgId);
465
+ if (state === TurnState.INITIALIZED) {
466
+ throw new Ably.ErrorInfo(
467
+ `unable to add events; start() must be called before addEvents() (turn ${turnId})`,
468
+ ErrorCode.InvalidArgument,
469
+ 400,
470
+ );
361
471
  }
472
+ await attachPromise;
362
473
 
363
- logger?.debug('Turn.addMessages(); messages published', { turnId, count: inputs.length });
364
- return { msgIds };
474
+ const turnOwnerClientId = turnManager.getClientId(turnId);
475
+
476
+ try {
477
+ for (const node of nodes) {
478
+ const headers = buildTransportHeaders({
479
+ role: 'assistant',
480
+ turnId,
481
+ msgId: node.msgId,
482
+ turnClientId: turnOwnerClientId,
483
+ amend: node.msgId,
484
+ });
485
+
486
+ const encoder = codec.createEncoder(channel, {
487
+ extras: { headers },
488
+ onMessage,
489
+ });
490
+
491
+ for (const event of node.events) {
492
+ await encoder.writeEvent(event);
493
+ }
494
+
495
+ await encoder.close();
496
+ }
497
+ } catch (error) {
498
+ const errInfo = new Ably.ErrorInfo(
499
+ `unable to publish events for turn ${turnId}; ${error instanceof Error ? error.message : String(error)}`,
500
+ ErrorCode.TurnLifecycleError,
501
+ 500,
502
+ error instanceof Ably.ErrorInfo ? error : undefined,
503
+ );
504
+ logger?.error('Turn.addEvents(); publish failed', { turnId });
505
+ throw errInfo;
506
+ }
507
+
508
+ logger?.debug('Turn.addEvents(); events published', { turnId, count: nodes.length });
365
509
  },
366
510
 
367
- // Spec: AIT-ST6
511
+ // Spec: AIT-ST6, AIT-ST6a, AIT-ST6b, AIT-ST6b1, AIT-ST6b2, AIT-ST6b3, AIT-ST6b4, AIT-ST6c
368
512
  streamResponse: async (
369
513
  stream: ReadableStream<TEvent>,
370
- streamOpts?: StreamResponseOptions,
514
+ streamOpts?: StreamResponseOptions<TEvent>,
371
515
  ): Promise<StreamResult> => {
372
516
  logger?.trace('Turn.streamResponse();', { turnId });
373
517
 
374
- if (!started) {
518
+ if (state === TurnState.INITIALIZED) {
375
519
  throw new Ably.ErrorInfo(
376
520
  `unable to stream response; start() must be called before streamResponse() (turn ${turnId})`,
377
521
  ErrorCode.InvalidArgument,
@@ -380,17 +524,16 @@ class DefaultServerTransport<TEvent, TMessage> implements ServerTransport<TEvent
380
524
  }
381
525
  await attachPromise;
382
526
 
383
- const signal = turnManager.getSignal(turnId);
384
527
  const turnOwnerClientId = turnManager.getClientId(turnId);
385
528
 
386
529
  // Per-operation parent overrides the turn-level default.
387
- const assistantParent =
388
- streamOpts?.parent === undefined ? (turnParent ?? undefined) : (streamOpts.parent ?? undefined);
530
+ const assistantParent = streamOpts?.parent === undefined ? turnParent : streamOpts.parent;
389
531
 
532
+ const msgId = crypto.randomUUID();
390
533
  const defaultHeaders = buildTransportHeaders({
391
534
  role: 'assistant',
392
535
  turnId,
393
- msgId: crypto.randomUUID(),
536
+ msgId,
394
537
  turnClientId: turnOwnerClientId,
395
538
  parent: assistantParent,
396
539
  forkOf: streamOpts?.forkOf ?? turnForkOf,
@@ -398,27 +541,39 @@ class DefaultServerTransport<TEvent, TMessage> implements ServerTransport<TEvent
398
541
  const encoder = codec.createEncoder(channel, {
399
542
  extras: { headers: defaultHeaders },
400
543
  onMessage,
544
+ messageId: msgId,
401
545
  });
402
546
 
403
- const result = await pipeStream(stream, encoder, signal, onAbort, logger);
547
+ const result = await pipeStream(stream, encoder, signal, onAbort, streamOpts?.resolveWriteOptions, logger);
548
+
549
+ if (result.error) {
550
+ const errInfo = new Ably.ErrorInfo(
551
+ `unable to stream response for turn ${turnId}; ${result.error.message}`,
552
+ ErrorCode.StreamError,
553
+ 500,
554
+ result.error instanceof Ably.ErrorInfo ? result.error : undefined,
555
+ );
556
+ logger?.error('Turn.streamResponse(); stream error', { turnId });
557
+ turnOnError?.(errInfo);
558
+ }
404
559
 
405
560
  logger?.debug('Turn.streamResponse(); stream finished', { turnId, reason: result.reason });
406
561
  return result;
407
562
  },
408
563
 
409
- // Spec: AIT-ST7
564
+ // Spec: AIT-ST7, AIT-ST7a, AIT-ST7b
410
565
  end: async (reason: TurnEndReason): Promise<void> => {
411
566
  logger?.trace('Turn.end();', { turnId, reason });
412
567
 
413
- if (!started) {
568
+ if (state === TurnState.INITIALIZED) {
414
569
  throw new Ably.ErrorInfo(
415
570
  `unable to end turn; start() must be called before end() (turn ${turnId})`,
416
571
  ErrorCode.InvalidArgument,
417
572
  400,
418
573
  );
419
574
  }
420
- if (ended) return;
421
- ended = true;
575
+ if (state === TurnState.ENDED) return;
576
+ state = TurnState.ENDED;
422
577
 
423
578
  try {
424
579
  await turnManager.endTurn(turnId, reason);
@@ -430,7 +585,6 @@ class DefaultServerTransport<TEvent, TMessage> implements ServerTransport<TEvent
430
585
  error instanceof Ably.ErrorInfo ? error : undefined,
431
586
  );
432
587
  logger?.error('Turn.end(); failed to publish turn-end', { turnId });
433
- turnOnError?.(errInfo);
434
588
  throw errInfo;
435
589
  } finally {
436
590
  registeredTurns.delete(turnId);
@@ -2,7 +2,8 @@
2
2
  * Client-side stream routing.
3
3
  *
4
4
  * Maintains a map of turnId to ReadableStreamController. Routes decoded events
5
- * to the correct stream. Closes streams on terminal events or explicit close.
5
+ * to the correct stream. Closes streams on terminal events, explicit close, or
6
+ * error.
6
7
  */
7
8
 
8
9
  import * as Ably from 'ably';
@@ -19,8 +20,10 @@ import type { TurnEntry } from './types.js';
19
20
  export interface StreamRouter<TEvent> {
20
21
  /** Register a new stream for a turnId. Returns the ReadableStream the consumer reads from. */
21
22
  createStream(turnId: string): ReadableStream<TEvent>;
22
- /** Close the stream for a turnId. Returns true if a stream was closed. */
23
+ /** Close the stream for a turnId. Returns true if a stream existed. */
23
24
  closeStream(turnId: string): boolean;
25
+ /** Error the stream for a turnId. The consumer's reader will reject with the given error. Returns true if a stream existed. */
26
+ errorStream(turnId: string, error: Ably.ErrorInfo): boolean;
24
27
  /** Enqueue an event to the correct stream. Returns true if routed successfully. */
25
28
  route(turnId: string, event: TEvent): boolean;
26
29
  /** Whether a specific turnId has an active stream. */
@@ -73,7 +76,22 @@ class DefaultStreamRouter<TEvent> implements StreamRouter<TEvent> {
73
76
  try {
74
77
  turn.controller.close();
75
78
  } catch {
76
- /* already closed */
79
+ /* consumer cancelled the stream */
80
+ }
81
+ this._turns.delete(turnId);
82
+ return true;
83
+ }
84
+
85
+ // Spec: AIT-CT14c
86
+ errorStream(turnId: string, error: Ably.ErrorInfo): boolean {
87
+ const turn = this._turns.get(turnId);
88
+ if (!turn) return false;
89
+
90
+ this._logger.debug('StreamRouter.errorStream(); erroring stream', { turnId });
91
+ try {
92
+ turn.controller.error(error);
93
+ } catch {
94
+ /* consumer cancelled the stream */
77
95
  }
78
96
  this._turns.delete(turnId);
79
97
  return true;