@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
@@ -0,0 +1,432 @@
1
+ /**
2
+ * `defineCodec` — composition packaging for a codec.
3
+ *
4
+ * A codec author supplies only its **parts** — a reducer, a per-direction
5
+ * descriptor table (the `output` and `input` builder functions), an optional
6
+ * decode lifecycle policy, and an optional agent identifier — and `defineCodec`
7
+ * assembles a fully-formed {@link Codec}: the generic encoder/decoder skeletons
8
+ * (built here, codec-agnostic), the reducer methods, and the well-known input
9
+ * factories (merged internally).
10
+ *
11
+ * Both directions are declarative descriptor tables driven by the generic
12
+ * encode/decode drivers. `defineCodec` hands each table a direction-scoped
13
+ * builder typed to that direction's union — `{ event, stream }` for outputs,
14
+ * `{ event, batch }` for inputs — so each construct's spec stays type-correct
15
+ * per direction under shared construct names, with no per-entry casts. Both
16
+ * sides build/read wire headers through the same shared field bindings, so
17
+ * encode and decode cannot drift.
18
+ */
19
+
20
+ import * as Ably from 'ably';
21
+
22
+ import { EVENT_AI_INPUT, EVENT_AI_OUTPUT, HEADER_RUN_ID } from '../../constants.js';
23
+ import { ErrorCode } from '../../errors.js';
24
+ import type { DecoderCore, DecoderCoreHooks } from './decoder.js';
25
+ import { createDecoderCore } from './decoder.js';
26
+ import type { EncoderCore, EncoderCoreOptions } from './encoder.js';
27
+ import { createEncoderCore } from './encoder.js';
28
+ import { KIND_HEADER, PART_TYPE_HEADER } from './field-bag.js';
29
+ import type { HeaderField } from './fields.js';
30
+ import { createInputDescriptorDecoder, type InputDescriptorDecoder } from './input-descriptor-decoder.js';
31
+ import { createInputDescriptorEncoder, type InputDescriptorEncoder } from './input-descriptor-encoder.js';
32
+ import { type InputBuilder, inputBuilder, type InputDescriptor } from './input-descriptors.js';
33
+ import { createOutputDescriptorDecoder } from './output-descriptor-decoder.js';
34
+ import { createOutputDescriptorEncoder, type OutputDescriptorEncoder } from './output-descriptor-encoder.js';
35
+ import { type OutputBuilder, outputBuilder, type OutputDescriptor } from './output-descriptors.js';
36
+ import type {
37
+ ChannelWriter,
38
+ Codec,
39
+ CodecEvent,
40
+ CodecInputEvent,
41
+ CodecMessage,
42
+ CodecOutputEvent,
43
+ DecodedMessage,
44
+ Decoder,
45
+ Encoder,
46
+ MessagePayload,
47
+ ReducerMeta,
48
+ StreamTrackerState,
49
+ WriteOptions,
50
+ } from './types.js';
51
+ import { type WellKnownInputFactories, wellKnownInputs } from './well-known-inputs.js';
52
+
53
+ // Re-exported so codec descriptor tables (e.g. the Vercel `inputs.ts` / `outputs.ts`)
54
+ // can type their builder parameter without reaching into the descriptor modules directly.
55
+ export type { InputBuilder } from './input-descriptors.js';
56
+ export type { OutputBuilder } from './output-descriptors.js';
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Decode lifecycle policy
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /** Context passed to a {@link LifecyclePolicy} `onDiscrete` repair function. */
63
+ export interface LifecycleDiscreteContext {
64
+ /** The inbound codec-tier headers (e.g. to recover a stream's message id). */
65
+ codecHeaders: Record<string, string>;
66
+ }
67
+
68
+ /**
69
+ * Declarative decode-time lifecycle repair, applied when joining a stream
70
+ * mid-flight (history compaction, rewind miss, partial page). Each function
71
+ * performs its side effect on the codec's lifecycle tracker (captured by the
72
+ * factory that builds the policy) and RETURNS lead-in events to PREPEND; the
73
+ * generic decoder ALWAYS runs the descriptor driver after and appends its
74
+ * output, so the policy never replaces a decode. A codec with no repair
75
+ * supplies no policy.
76
+ * @template TOutput - The codec's output union.
77
+ */
78
+ export interface LifecyclePolicy<TOutput> {
79
+ /**
80
+ * Keyed on the discrete codec `kind`. Returns lead-in events to prepend
81
+ * (empty array = none) after applying any tracker side effect.
82
+ */
83
+ onDiscrete?: Record<string, (runId: string, ctx: LifecycleDiscreteContext) => TOutput[]>;
84
+ /** Lead-in prepended to a stream's start events (mid-stream-join pre-roll). */
85
+ onStreamStart?: (runId: string, tracker: StreamTrackerState) => TOutput[];
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // defineCodec config + result
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /**
93
+ * The reducer parts a codec supplies. `TProjection` and `TMessage` infer from
94
+ * these, so `defineCodec` callers need not spell them out.
95
+ * @template TInput - The codec's input union.
96
+ * @template TOutput - The codec's output union.
97
+ * @template TProjection - The per-node projection the reducer folds into.
98
+ * @template TMessage - The per-message domain type.
99
+ */
100
+ export interface CodecReducer<TInput, TOutput, TProjection, TMessage> {
101
+ /** Build an empty projection for a node. */
102
+ init: () => TProjection;
103
+ /** Fold one direction-tagged input or output event into the projection. */
104
+ fold: (state: TProjection, event: CodecEvent<TInput, TOutput>, meta: ReducerMeta) => TProjection;
105
+ /** Extract the per-message list (each paired with its codec-message-id). */
106
+ getMessages: (projection: TProjection) => CodecMessage<TMessage>[];
107
+ }
108
+
109
+ /**
110
+ * The parts a codec supplies to {@link defineCodec}.
111
+ * @template TInput - The codec's input union.
112
+ * @template TOutput - The codec's output union.
113
+ * @template TProjection - The per-node projection the reducer folds into.
114
+ * @template TMessage - The per-message domain type.
115
+ */
116
+ export interface DefineCodecConfig<
117
+ TInput extends { kind: string },
118
+ TOutput extends { type: string },
119
+ TProjection,
120
+ TMessage,
121
+ > {
122
+ /** Optional Ably-Agent identifier registered on the channel; omit to opt out. */
123
+ adapterTag?: string;
124
+ /** Reducer parts; `TProjection` / `TMessage` infer from here. */
125
+ reducer: CodecReducer<TInput, TOutput, TProjection, TMessage>;
126
+ /**
127
+ * The declarative output (`ai-output`) descriptor table, returned from the
128
+ * injected `{ event, stream }` builder (both curried on `TOutput`).
129
+ */
130
+ output: (b: OutputBuilder<TOutput>) => readonly OutputDescriptor<TOutput>[];
131
+ /**
132
+ * The declarative input (`ai-input`) descriptor table, returned from the
133
+ * injected `{ event, batch }` builder (both curried on `TInput`).
134
+ */
135
+ input: (b: InputBuilder<TInput>) => readonly InputDescriptor<TInput>[];
136
+ /**
137
+ * Factory for a fresh decode lifecycle policy per decoder instance (the
138
+ * policy's closures capture a fresh, per-decoder lifecycle tracker). Omit
139
+ * for a codec with no mid-stream-join repair.
140
+ */
141
+ decodeLifecycle?: () => LifecyclePolicy<TOutput>;
142
+ }
143
+
144
+ /**
145
+ * A codec assembled by {@link defineCodec}: a conforming {@link Codec} whose
146
+ * well-known input factories are typed concretely by {@link WellKnownInputFactories}
147
+ * (so `createToolResult` etc. are callable without a guard). The factory methods
148
+ * are sourced from `WellKnownInputFactories` rather than `Codec` because the
149
+ * former types them against `UserMessageOf<TInput>` / `ToolResultPayloadOf<TInput>`
150
+ * — equal to the codec's `TMessage` / payloads for every real codec, but not
151
+ * provably so to the generic type system. At a concrete call site a
152
+ * `DefinedCodec` is assignable to the corresponding `Codec`.
153
+ */
154
+ export type DefinedCodec<
155
+ TInput extends CodecInputEvent,
156
+ TOutput extends CodecOutputEvent,
157
+ TProjection,
158
+ TMessage,
159
+ > = Omit<Codec<TInput, TOutput, TProjection, TMessage>, keyof WellKnownInputFactories<TInput>> &
160
+ WellKnownInputFactories<TInput>;
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Generic encoder
164
+ // ---------------------------------------------------------------------------
165
+
166
+ class DefaultCodecEncoder<TInput extends CodecInputEvent, TOutput extends CodecOutputEvent> implements Encoder<
167
+ TInput,
168
+ TOutput
169
+ > {
170
+ private readonly _core: EncoderCore;
171
+ private readonly _messageId: string | undefined;
172
+ private readonly _outputEncoder: OutputDescriptorEncoder<TOutput>;
173
+ private readonly _inputEncoder: InputDescriptorEncoder<TInput>;
174
+
175
+ constructor(
176
+ writer: ChannelWriter,
177
+ options: EncoderCoreOptions,
178
+ outputEncoder: OutputDescriptorEncoder<TOutput>,
179
+ inputEncoder: InputDescriptorEncoder<TInput>,
180
+ ) {
181
+ this._core = createEncoderCore(writer, options);
182
+ this._messageId = options.messageId;
183
+ this._outputEncoder = outputEncoder;
184
+ this._inputEncoder = inputEncoder;
185
+ }
186
+
187
+ async publishInput(input: TInput, options?: WriteOptions): Promise<void> {
188
+ // No `messageId` threads into inputs — user-message parts carry no
189
+ // transport codec-message-id today; inputs rely on opts.messageId stamped
190
+ // by the client session.
191
+ await this._inputEncoder.encode(input, this._core, { opts: options });
192
+ }
193
+
194
+ async publishOutput(output: TOutput, options?: WriteOptions): Promise<void> {
195
+ await this._outputEncoder.encode(output, this._core, { messageId: this._messageId, opts: options });
196
+ }
197
+
198
+ async cancelStreams(): Promise<void> {
199
+ await this._core.cancelAllStreams();
200
+ }
201
+
202
+ async close(): Promise<void> {
203
+ await this._core.close();
204
+ }
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Generic decoder
209
+ // ---------------------------------------------------------------------------
210
+
211
+ const decodeDiscretePayload = <TInput extends { kind: string }, TOutput>(
212
+ payload: MessagePayload,
213
+ outputDecoder: ReturnType<typeof createOutputDescriptorDecoder<TOutput & { type: string }>>,
214
+ inputDecoder: InputDescriptorDecoder<TInput>,
215
+ lifecycle: LifecyclePolicy<TOutput> | undefined,
216
+ ): (TInput | TOutput)[] => {
217
+ const codecHeaders = payload.codecHeaders ?? {};
218
+ const transportHeaders = payload.transportHeaders ?? {};
219
+ const codecKind = codecHeaders[KIND_HEADER] ?? '';
220
+
221
+ if (payload.name === EVENT_AI_INPUT) {
222
+ return inputDecoder.decode({ codecKind, data: payload.data, codecHeaders, transportHeaders });
223
+ }
224
+
225
+ if (payload.name === EVENT_AI_OUTPUT) {
226
+ const runId = transportHeaders[HEADER_RUN_ID] ?? '';
227
+ // Lifecycle repair runs its side effect and returns lead-in events; the
228
+ // descriptor driver always decodes after and its output is appended.
229
+ // The `kind` comes off the wire, so the policy lookup must be own-property
230
+ // only — a crafted kind such as 'valueOf' or 'toString' would otherwise
231
+ // resolve through Object.prototype and corrupt the decode.
232
+ const onDiscrete = lifecycle?.onDiscrete;
233
+ const repair = onDiscrete !== undefined && Object.hasOwn(onDiscrete, codecKind) ? onDiscrete[codecKind] : undefined;
234
+ const pre = repair?.(runId, { codecHeaders }) ?? [];
235
+ return [...pre, ...outputDecoder.decodeDiscrete(codecKind, codecHeaders, transportHeaders, payload.data)];
236
+ }
237
+
238
+ return [];
239
+ };
240
+
241
+ // Only outputs stream: a streamed message under any other wire name (a
242
+ // foreign or crafted ai-input stream) must not rebuild through the output
243
+ // stream path — its events would be mislabelled as inputs by the
244
+ // direction-routing decode. Enforces the invariant the decode cast relies on.
245
+ const isOutputStream = (tracker: StreamTrackerState): boolean => tracker.name === EVENT_AI_OUTPUT;
246
+
247
+ const buildHooks = <TInput extends { kind: string }, TOutput extends { type: string }>(
248
+ outputDecoder: ReturnType<typeof createOutputDescriptorDecoder<TOutput>>,
249
+ inputDecoder: InputDescriptorDecoder<TInput>,
250
+ lifecycle: LifecyclePolicy<TOutput> | undefined,
251
+ ): DecoderCoreHooks<TInput | TOutput> => ({
252
+ buildStartEvents: (tracker) => {
253
+ if (!isOutputStream(tracker)) return [];
254
+ const runId = tracker.transportHeaders[HEADER_RUN_ID] ?? '';
255
+ const pre = lifecycle?.onStreamStart?.(runId, tracker) ?? [];
256
+ return [...pre, ...outputDecoder.buildStart(tracker)];
257
+ },
258
+ buildDeltaEvents: (tracker, delta) => (isOutputStream(tracker) ? outputDecoder.buildDelta(tracker, delta) : []),
259
+ buildEndEvents: (tracker, closingCodecHeaders) =>
260
+ isOutputStream(tracker) ? outputDecoder.buildEnd(tracker, closingCodecHeaders) : [],
261
+ decodeDiscrete: (payload) => decodeDiscretePayload(payload, outputDecoder, inputDecoder, lifecycle),
262
+ });
263
+
264
+ class DefaultCodecDecoder<TInput extends CodecInputEvent, TOutput extends CodecOutputEvent> implements Decoder<
265
+ TInput,
266
+ TOutput
267
+ > {
268
+ private readonly _core: DecoderCore<TInput | TOutput>;
269
+
270
+ constructor(core: DecoderCore<TInput | TOutput>) {
271
+ this._core = core;
272
+ }
273
+
274
+ decode(message: Ably.InboundMessage): DecodedMessage<TInput, TOutput> {
275
+ const events = this._core.decode(message);
276
+ // A single inbound message carries one wire name (ai-input XOR ai-output), so the
277
+ // name fixes the direction of every event decoded from it. The wire name is the
278
+ // authoritative direction signal — never the event's in-memory shape.
279
+ if (message.name === EVENT_AI_INPUT) {
280
+ // CAST: an ai-input message decodes only to inputs.
281
+ return { inputs: events as TInput[], outputs: [] };
282
+ }
283
+ // CAST: every other message is ai-output — the only other wire name the core decodes
284
+ // (unrecognised names yield no events) — so its events are all outputs.
285
+ return { inputs: [], outputs: events as TOutput[] };
286
+ }
287
+ }
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // Table validation
291
+ // ---------------------------------------------------------------------------
292
+
293
+ /**
294
+ * Reserve `literal` in `seen` under a human-readable owner description,
295
+ * throwing if another descriptor already holds it. Dispatch literals must be
296
+ * unique within their namespace — a duplicate would silently route through
297
+ * whichever descriptor registered last.
298
+ * @param seen - The namespace's literal → owner registry, mutated in place.
299
+ * @param literal - The dispatch literal to reserve.
300
+ * @param owner - Human-readable description of the declaring descriptor (used in the error).
301
+ */
302
+ const reserve = (seen: Map<string, string>, literal: string, owner: string): void => {
303
+ const holder = seen.get(literal);
304
+ if (holder !== undefined) {
305
+ throw new Ably.ErrorInfo(
306
+ `unable to define codec; dispatch literal '${literal}' is declared by both ${holder} and ${owner}`,
307
+ ErrorCode.InvalidArgument,
308
+ 400,
309
+ );
310
+ }
311
+ seen.set(literal, owner);
312
+ };
313
+
314
+ /**
315
+ * Throw when a declared field binds one of the driver-reserved header keys.
316
+ * @param fields - The descriptor's declared header fields.
317
+ * @param owner - Human-readable description of the declaring descriptor (used in the error).
318
+ */
319
+ const rejectReservedFieldKeys = (fields: readonly HeaderField<unknown>[], owner: string): void => {
320
+ for (const field of fields) {
321
+ if (field.key === KIND_HEADER || field.key === PART_TYPE_HEADER) {
322
+ throw new Ably.ErrorInfo(
323
+ `unable to define codec; ${owner} binds the driver-reserved header key '${field.key}'`,
324
+ ErrorCode.InvalidArgument,
325
+ 400,
326
+ );
327
+ }
328
+ }
329
+ };
330
+
331
+ /**
332
+ * Fail-fast validation of the assembled descriptor tables, run once per
333
+ * `defineCodec` call. Catches author mistakes the drivers would otherwise
334
+ * surface as silent last-wins routing or encode/decode asymmetry:
335
+ *
336
+ * - duplicate dispatch literals within a namespace — the domain chunk `type`
337
+ * namespace (discrete event types + stream phase types, which drive encode
338
+ * dispatch) and the wire `kind` namespace (discrete event types + stream
339
+ * family kinds, which drive decode dispatch);
340
+ * - duplicate input `kind`s and duplicate `partType`s within a batch;
341
+ * - field bindings on the driver-reserved `kind` / `partType` header keys.
342
+ * @param outputs - The assembled output descriptor table.
343
+ * @param inputs - The assembled input descriptor table.
344
+ */
345
+ const validateTables = <TInput, TOutput>(
346
+ outputs: readonly OutputDescriptor<TOutput>[],
347
+ inputs: readonly InputDescriptor<TInput>[],
348
+ ): void => {
349
+ const chunkTypes = new Map<string, string>();
350
+ const wireKinds = new Map<string, string>();
351
+ for (const descriptor of outputs) {
352
+ if (descriptor.construct === 'event') {
353
+ const owner = `output event '${descriptor.type}'`;
354
+ reserve(chunkTypes, descriptor.type, owner);
355
+ reserve(wireKinds, descriptor.type, owner);
356
+ rejectReservedFieldKeys(descriptor.fields, owner);
357
+ } else {
358
+ const owner = `output stream '${descriptor.kind}'`;
359
+ reserve(wireKinds, descriptor.kind, owner);
360
+ for (const phase of [descriptor.start, descriptor.delta, descriptor.end]) {
361
+ reserve(chunkTypes, phase, owner);
362
+ }
363
+ rejectReservedFieldKeys(descriptor.fields, owner);
364
+ }
365
+ }
366
+
367
+ const inputKinds = new Map<string, string>();
368
+ for (const descriptor of inputs) {
369
+ const owner = `input ${descriptor.construct} '${descriptor.kind}'`;
370
+ reserve(inputKinds, descriptor.kind, owner);
371
+ if (descriptor.construct === 'event') {
372
+ rejectReservedFieldKeys(descriptor.fields, owner);
373
+ } else {
374
+ const partTypes = new Map<string, string>();
375
+ for (const part of descriptor.parts) {
376
+ const partOwner = `${owner} part '${part.partType}'`;
377
+ reserve(partTypes, part.partType, partOwner);
378
+ rejectReservedFieldKeys(part.fields, partOwner);
379
+ }
380
+ }
381
+ }
382
+ };
383
+
384
+ // ---------------------------------------------------------------------------
385
+ // Factory
386
+ // ---------------------------------------------------------------------------
387
+
388
+ /**
389
+ * Assemble a fully-formed {@link Codec} from a codec's parts. Curried on the
390
+ * input/output unions so `TProjection` / `TMessage` infer from `config.reducer`
391
+ * — a caller writes `defineCodec<TInput, TOutput>()({ ... })` and never spells
392
+ * out the projection or message types.
393
+ * @template TInput - The codec's input union.
394
+ * @template TOutput - The codec's output union.
395
+ * @returns A function taking the codec's parts and returning the assembled codec.
396
+ */
397
+ export const defineCodec =
398
+ <TInput extends CodecInputEvent, TOutput extends CodecOutputEvent>() =>
399
+ <TProjection, TMessage>(
400
+ config: DefineCodecConfig<TInput, TOutput, TProjection, TMessage>,
401
+ ): DefinedCodec<TInput, TOutput, TProjection, TMessage> => {
402
+ const { reducer, decodeLifecycle } = config;
403
+ // Build the direction-scoped builders, hand them to the codec's table
404
+ // functions, and collect the descriptor arrays the drivers consume.
405
+ const outputs = config.output(outputBuilder<TOutput>());
406
+ const inputs = config.input(inputBuilder<TInput>());
407
+ validateTables(outputs, inputs);
408
+ // The descriptor drivers are pure functions of the (fixed) tables — build
409
+ // them once here and share them across every encoder/decoder instance.
410
+ const outputEncoder = createOutputDescriptorEncoder(outputs, EVENT_AI_OUTPUT);
411
+ const inputEncoder = createInputDescriptorEncoder(inputs, EVENT_AI_INPUT);
412
+ const outputDecoder = createOutputDescriptorDecoder(outputs);
413
+ const inputDecoder = createInputDescriptorDecoder(inputs);
414
+ return {
415
+ // adapterTag is optional on Codec; only set it when supplied so a codec
416
+ // can opt out of Ably-Agent registration.
417
+ ...(config.adapterTag === undefined ? {} : { adapterTag: config.adapterTag }),
418
+ init: reducer.init,
419
+ fold: reducer.fold,
420
+ getMessages: reducer.getMessages,
421
+ createEncoder: (writer, options = {}) => new DefaultCodecEncoder(writer, options, outputEncoder, inputEncoder),
422
+ createDecoder: () =>
423
+ new DefaultCodecDecoder<TInput, TOutput>(
424
+ // The lifecycle policy (and its tracker) stays per-decoder: each
425
+ // decoder instance gets independent per-run phase state. No options
426
+ // thread through: Codec.createDecoder takes none, so accepting any
427
+ // here would be unreachable surface.
428
+ createDecoderCore(buildHooks(outputDecoder, inputDecoder, decodeLifecycle?.()), {}),
429
+ ),
430
+ ...wellKnownInputs<TInput>(),
431
+ };
432
+ };
@@ -47,6 +47,8 @@ interface StreamState {
47
47
  /** Codec-tier headers repeated on every append (`extras.ai.codec`). */
48
48
  persistentCodec: Record<string, string>;
49
49
  cancelled: boolean;
50
+ /** Set by `closeStream` — a completed stream must never receive a cancelled terminal. */
51
+ completed: boolean;
50
52
  }
51
53
 
52
54
  /**
@@ -80,7 +82,7 @@ export interface EncoderCore {
80
82
 
81
83
  /**
82
84
  * Append data to an in-flight streamed message. Fire-and-forget: errors are
83
- * collected internally and surfaced by {@link closeStream}, {@link cancelStream},
85
+ * collected internally and surfaced by {@link closeStream},
84
86
  * {@link cancelAllStreams} or {@link close}.
85
87
  * @throws {Ably.ErrorInfo} InvalidArgument if there is no active stream for `streamId` or the core is closed.
86
88
  */
@@ -93,12 +95,6 @@ export interface EncoderCore {
93
95
  */
94
96
  closeStream(streamId: string, payload: StreamPayload): Promise<void>;
95
97
 
96
- /**
97
- * Cancel a single in-progress stream (status:cancelled) and flush all
98
- * pending appends for recovery before returning.
99
- */
100
- cancelStream(streamId: string, opts?: WriteOptions): Promise<void>;
101
-
102
98
  /**
103
99
  * Cancel all in-progress streams (status:cancelled) and flush all
104
100
  * pending appends for recovery before returning.
@@ -116,7 +112,6 @@ export interface EncoderCore {
116
112
  // Spec: AIT-CD1
117
113
  class DefaultEncoderCore implements EncoderCore {
118
114
  private readonly _writer: ChannelWriter;
119
- private readonly _defaultClientId: string | undefined;
120
115
  private readonly _defaultExtras: Extras | undefined;
121
116
  private readonly _onMessageHook: (message: Ably.Message) => void;
122
117
  private readonly _logger: Logger | undefined;
@@ -127,7 +122,6 @@ class DefaultEncoderCore implements EncoderCore {
127
122
 
128
123
  constructor(writer: ChannelWriter, options: EncoderCoreOptions = {}) {
129
124
  this._writer = writer;
130
- this._defaultClientId = options.clientId;
131
125
  this._defaultExtras = options.extras;
132
126
  this._onMessageHook =
133
127
  options.onMessage ??
@@ -164,12 +158,10 @@ class DefaultEncoderCore implements EncoderCore {
164
158
  transport[HEADER_STREAM_ID] = streamId;
165
159
  const codec = payload.codecHeaders ?? {};
166
160
 
167
- const clientId = this._resolveClientId(opts);
168
161
  const msg: Ably.Message = {
169
162
  name: payload.name,
170
163
  data: payload.data,
171
164
  extras: { ai: this._aiExtras(transport, codec) },
172
- ...(clientId ? { clientId } : {}),
173
165
  };
174
166
 
175
167
  this._invokeOnMessage(msg);
@@ -193,6 +185,7 @@ class DefaultEncoderCore implements EncoderCore {
193
185
  persistentTransport: transport,
194
186
  persistentCodec: codec,
195
187
  cancelled: false,
188
+ completed: false,
196
189
  });
197
190
 
198
191
  this._logger?.debug('DefaultEncoderCore.startStream(); stream started', {
@@ -244,6 +237,9 @@ class DefaultEncoderCore implements EncoderCore {
244
237
 
245
238
  // Accumulate closing data so recovery has the full content
246
239
  tracker.accumulated += payload.data;
240
+ // Mark completed so a later cancelAllStreams (e.g. pipeStream terminating
241
+ // streams left open by an agent self-abort) skips this stream.
242
+ tracker.completed = true;
247
243
 
248
244
  const { transport, codec } = this._buildClosing(tracker, payload);
249
245
  transport[HEADER_STATUS] = 'complete';
@@ -263,46 +259,16 @@ class DefaultEncoderCore implements EncoderCore {
263
259
  this._logger?.debug('DefaultEncoderCore.closeStream(); stream closed', { streamId });
264
260
  }
265
261
 
266
- // Spec: AIT-CD5, AIT-CD5b
267
- async cancelStream(streamId: string, opts?: WriteOptions): Promise<void> {
268
- this._assertNotClosed();
269
- this._logger?.trace('DefaultEncoderCore.cancelStream();', { streamId });
270
-
271
- const tracker = this._trackers.get(streamId);
272
- if (!tracker) {
273
- throw new Ably.ErrorInfo(
274
- `unable to cancel stream; no active stream for streamId '${streamId}'`,
275
- ErrorCode.InvalidArgument,
276
- 400,
277
- );
278
- }
279
-
280
- tracker.cancelled = true;
281
-
282
- const { transport, codec } = this._buildClosing(tracker, undefined, opts);
283
- transport[HEADER_STATUS] = 'cancelled';
284
-
285
- const msg: Ably.Message = {
286
- serial: tracker.serial,
287
- data: '',
288
- extras: { ai: this._aiExtras(transport, codec) },
289
- };
290
-
291
- this._invokeOnMessage(msg);
292
- const p = this._writer.appendMessage(msg);
293
- this._pending.push({ promise: p, streamId });
294
-
295
- await this._flushPending();
296
-
297
- this._logger?.debug('DefaultEncoderCore.cancelStream(); stream cancelled', { streamId });
298
- }
299
-
300
- // Spec: AIT-CD5a
262
+ // Spec: AIT-CD5, AIT-CD5a
301
263
  async cancelAllStreams(opts?: WriteOptions): Promise<void> {
302
264
  this._assertNotClosed();
303
265
  this._logger?.trace('DefaultEncoderCore.cancelAllStreams();', { streamCount: this._trackers.size });
304
266
 
305
267
  for (const tracker of this._trackers.values()) {
268
+ // Idempotent and complete-safe: a stream already cancelled must not be
269
+ // re-appended on a repeat call, and a stream that closed with
270
+ // status:complete must never receive a cancelled terminal.
271
+ if (tracker.cancelled || tracker.completed) continue;
306
272
  tracker.cancelled = true;
307
273
 
308
274
  const { transport, codec } = this._buildClosing(tracker, undefined, opts);
@@ -432,10 +398,6 @@ class DefaultEncoderCore implements EncoderCore {
432
398
  }
433
399
  }
434
400
 
435
- private _resolveClientId(opts?: WriteOptions): string | undefined {
436
- return opts?.clientId ?? this._defaultClientId;
437
- }
438
-
439
401
  /**
440
402
  * Build the transport-tier header record for a message: caller-configured
441
403
  * transport headers (default extras + per-write overrides) layered with any
@@ -476,8 +438,6 @@ class DefaultEncoderCore implements EncoderCore {
476
438
  // events that also happen to be discrete (stream: false).
477
439
  transport[HEADER_DISCRETE] = 'true';
478
440
  }
479
- const clientId = this._resolveClientId(opts);
480
-
481
441
  const msg: Ably.Message = {
482
442
  name: payload.name,
483
443
  data: payload.data,
@@ -485,7 +445,6 @@ class DefaultEncoderCore implements EncoderCore {
485
445
  ai: this._aiExtras(transport, payload.codecHeaders ?? {}),
486
446
  ...(payload.ephemeral ? { ephemeral: true } : {}),
487
447
  },
488
- ...(clientId ? { clientId } : {}),
489
448
  };
490
449
 
491
450
  this._invokeOnMessage(msg);
@@ -520,7 +479,7 @@ class DefaultEncoderCore implements EncoderCore {
520
479
  /**
521
480
  * Create an encoder core bound to the given channel writer.
522
481
  * @param writer - The channel writer to publish messages through.
523
- * @param options - Encoder configuration (clientId, extras, hooks, logger).
482
+ * @param options - Encoder configuration (extras, hooks, logger).
524
483
  * @returns A new {@link EncoderCore} instance.
525
484
  */
526
485
  export const createEncoderCore = (writer: ChannelWriter, options: EncoderCoreOptions = {}): EncoderCore =>