@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
package/src/utils.ts CHANGED
@@ -6,7 +6,25 @@
6
6
  * the other.
7
7
  */
8
8
 
9
- import type * as Ably from 'ably';
9
+ import * as Ably from 'ably';
10
+
11
+ /**
12
+ * Extract a human-readable message from an unknown thrown value.
13
+ * @param error - The thrown value.
14
+ * @returns The error's `message` when it is an `Error`, otherwise its string form.
15
+ */
16
+ export const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
17
+
18
+ /**
19
+ * Narrow an unknown thrown value to an `Ably.ErrorInfo` for use as a wrapping
20
+ * `cause`, returning `undefined` when it is not one. Pass the result as the
21
+ * fourth argument to the `Ably.ErrorInfo` constructor to preserve the error
22
+ * chain without asserting a type the value may not have.
23
+ * @param error - The thrown value.
24
+ * @returns The value when it is an `Ably.ErrorInfo`, otherwise `undefined`.
25
+ */
26
+ export const errorCause = (error: unknown): Ably.ErrorInfo | undefined =>
27
+ error instanceof Ably.ErrorInfo ? error : undefined;
10
28
 
11
29
  /**
12
30
  * Read one tier of the SDK's `extras.ai` namespace from an Ably message.
@@ -65,20 +83,19 @@ export const parseJson = (value: string | undefined): unknown => {
65
83
  };
66
84
 
67
85
  /**
68
- * Set a header value if defined, skipping undefined and null. Strings are set directly,
69
- * booleans and numbers are stringified, objects are JSON-serialized.
70
- * @param headers - The headers object to mutate.
71
- * @param key - The header key.
72
- * @param value - The value to set.
86
+ * Parse a string as JSON, falling back to the raw string when it isn't valid
87
+ * JSON. An empty string yields `undefined`. Used for accumulated stream text
88
+ * whose payload may be JSON or a plain string.
89
+ * @param value - The string to parse.
90
+ * @returns The parsed value, the raw string on parse failure, or undefined if empty.
73
91
  */
74
- export const setIfPresent = (headers: Record<string, string>, key: string, value: unknown): void => {
75
- if (value === undefined || value === null) return;
76
- if (typeof value === 'string') {
77
- headers[key] = value;
78
- } else if (typeof value === 'boolean' || typeof value === 'number') {
79
- headers[key] = String(value);
80
- } else if (typeof value === 'object') {
81
- headers[key] = JSON.stringify(value);
92
+ export const parseJsonOrString = (value: string): unknown => {
93
+ if (!value) return undefined;
94
+ try {
95
+ // CAST: JSON.parse returns any; unknown is the safe trust-boundary type.
96
+ return JSON.parse(value) as unknown;
97
+ } catch {
98
+ return value;
82
99
  }
83
100
  };
84
101
 
@@ -130,14 +147,6 @@ export const compareBySerial = (a: HasSerial, b: HasSerial): number => {
130
147
  return 0;
131
148
  };
132
149
 
133
- /**
134
- * Read a domain header value from a codec-tier headers record.
135
- * @param headers - The codec headers record to read from.
136
- * @param key - The domain key (e.g. `'toolCallId'`).
137
- * @returns The header value, or undefined if absent.
138
- */
139
- export const getDomainHeader = (headers: Record<string, string>, key: string): string | undefined => headers[key];
140
-
141
150
  /**
142
151
  * Mapped type that converts properties whose type includes `undefined`
143
152
  * into optional properties with `undefined` excluded from the value.
@@ -171,78 +180,3 @@ export const stripUndefined = <T extends Record<string, unknown>>(obj: T): Strip
171
180
  // required keys are always present, optional keys are absent when undefined.
172
181
  return result as Stripped<T>;
173
182
  };
174
-
175
- // ---------------------------------------------------------------------------
176
- // DomainHeaderReader — typed accessors for domain headers
177
- // ---------------------------------------------------------------------------
178
-
179
- /**
180
- * Typed accessor wrapper around a headers record for reading domain headers.
181
- * Reduces repetitive `getDomainHeader` + `parseBool` / `parseJson` chains.
182
- */
183
- export interface DomainHeaderReader {
184
- /** Read a domain header as a string, or undefined if absent. */
185
- str(key: string): string | undefined;
186
- /** Read a domain header as a string, falling back to a default if absent. */
187
- strOr(key: string, fallback: string): string;
188
- /** Read a domain header as a boolean: `true` only for the exact string "true", `false` for any other present value, or undefined if absent. */
189
- bool(key: string): boolean | undefined;
190
- /** Read a domain header as parsed JSON, or undefined if absent or invalid. */
191
- json(key: string): unknown;
192
- }
193
-
194
- /**
195
- * Create a {@link DomainHeaderReader} over a headers record.
196
- * @param headers - The raw headers record to read domain headers from.
197
- * @returns A typed accessor for domain header values.
198
- */
199
- export const headerReader = (headers: Record<string, string>): DomainHeaderReader => ({
200
- str: (key: string) => getDomainHeader(headers, key),
201
- strOr: (key: string, fallback: string) => getDomainHeader(headers, key) ?? fallback,
202
- bool: (key: string) => parseBool(getDomainHeader(headers, key)),
203
- json: (key: string) => parseJson(getDomainHeader(headers, key)),
204
- });
205
-
206
- // ---------------------------------------------------------------------------
207
- // DomainHeaderWriter — typed builder for domain headers
208
- // ---------------------------------------------------------------------------
209
-
210
- /**
211
- * Fluent builder for constructing domain header records with typed setters.
212
- * Mirrors {@link DomainHeaderReader} with the same method names for symmetry.
213
- * Undefined values are silently skipped on all setters.
214
- */
215
- export interface DomainHeaderWriter {
216
- /** Set a string domain header. Skips if value is undefined. */
217
- str(key: string, value: string | undefined): DomainHeaderWriter;
218
- /** Set a boolean domain header (serialized as "true"/"false"). Skips if value is undefined. */
219
- bool(key: string, value: boolean | undefined): DomainHeaderWriter;
220
- /** Set a JSON-serialized domain header. Skips if value is undefined or null. */
221
- json(key: string, value: unknown): DomainHeaderWriter;
222
- /** Return the accumulated headers record. */
223
- build(): Record<string, string>;
224
- }
225
-
226
- /**
227
- * Create a {@link DomainHeaderWriter} for building a codec-tier headers record.
228
- * @returns A fluent builder that accumulates codec headers under their bare keys.
229
- */
230
- export const headerWriter = (): DomainHeaderWriter => {
231
- const h: Record<string, string> = {};
232
- const writer: DomainHeaderWriter = {
233
- str: (key: string, value: string | undefined) => {
234
- if (value !== undefined) h[key] = value;
235
- return writer;
236
- },
237
- bool: (key: string, value: boolean | undefined) => {
238
- if (value !== undefined) h[key] = String(value);
239
- return writer;
240
- },
241
- json: (key: string, value: unknown) => {
242
- if (value !== undefined && value !== null) h[key] = JSON.stringify(value);
243
- return writer;
244
- },
245
- build: () => h,
246
- };
247
- return writer;
248
- };
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Vercel decode lifecycle policy — mid-stream-join repair.
3
+ *
4
+ * When a client joins a stream mid-flight (history compaction, rewind miss,
5
+ * partial page), the reducer must still see a clean `start` / `start-step`
6
+ * pre-roll. This policy keys that repair on the discrete codec `kind` and on
7
+ * stream start; each entry performs its tracker side effect and returns the
8
+ * lead-in chunks the generic decoder prepends before running the descriptor
9
+ * driver. A fresh policy (and tracker) is built per decoder instance.
10
+ */
11
+
12
+ import type * as AI from 'ai';
13
+
14
+ import { createLifecycleTracker, type LifecyclePolicy, type LifecycleTracker } from '../../core/codec/index.js';
15
+ import { stripUndefined } from '../../utils.js';
16
+ import type { VercelOutput } from './events.js';
17
+ import { fMessageId } from './fields.js';
18
+
19
+ const createVercelLifecycleTracker = (): LifecycleTracker<AI.UIMessageChunk> =>
20
+ createLifecycleTracker<AI.UIMessageChunk>([
21
+ {
22
+ key: 'start',
23
+ build: (ctx) => [stripUndefined({ type: 'start' as const, messageId: ctx.messageId })],
24
+ },
25
+ {
26
+ key: 'start-step',
27
+ build: () => [{ type: 'start-step' as const }],
28
+ },
29
+ ]);
30
+
31
+ /**
32
+ * Build a fresh Vercel decode lifecycle policy (with its own tracker). Passed
33
+ * to `defineCodec` as the `decodeLifecycle` factory so each decoder instance
34
+ * gets independent per-run phase state.
35
+ * @returns A {@link LifecyclePolicy} for the Vercel output union.
36
+ */
37
+ export const createVercelDecodeLifecycle = (): LifecyclePolicy<VercelOutput> => {
38
+ const tracker = createVercelLifecycleTracker();
39
+ return {
40
+ onDiscrete: {
41
+ start: (runId) => {
42
+ tracker.markEmitted(runId, 'start');
43
+ return [];
44
+ },
45
+ 'start-step': (runId) => {
46
+ tracker.markEmitted(runId, 'start-step');
47
+ return [];
48
+ },
49
+ 'finish-step': (runId) => {
50
+ tracker.resetPhase(runId, 'start-step');
51
+ return [];
52
+ },
53
+ finish: (runId) => {
54
+ tracker.clearScope(runId);
55
+ return [];
56
+ },
57
+ error: (runId) => {
58
+ tracker.clearScope(runId);
59
+ return [];
60
+ },
61
+ abort: (runId) => {
62
+ tracker.clearScope(runId);
63
+ return [];
64
+ },
65
+ 'tool-input': (runId, ctx) => tracker.ensurePhases(runId, { messageId: fMessageId.read(ctx.codecHeaders) }),
66
+ },
67
+ onStreamStart: (runId, trackerState) =>
68
+ tracker.ensurePhases(runId, { messageId: fMessageId.read(trackerState.codecHeaders) }),
69
+ };
70
+ };
@@ -20,7 +20,7 @@ import type {
20
20
  ToolResult,
21
21
  ToolResultError,
22
22
  UserMessage,
23
- } from '../../core/codec/types.js';
23
+ } from '../../core/codec/index.js';
24
24
 
25
25
  // ---------------------------------------------------------------------------
26
26
  // Domain payloads
@@ -83,5 +83,3 @@ export type VercelOutput = AI.UIMessageChunk;
83
83
  // ---------------------------------------------------------------------------
84
84
  // Projection re-export
85
85
  // ---------------------------------------------------------------------------
86
-
87
- export type { VercelProjection } from './reducer.js';
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Shared Vercel codec header-field bindings.
3
+ *
4
+ * Each field binds a codec header key to its value type once (see
5
+ * {@link HeaderField}); the output/input descriptors and escape hatches all
6
+ * read and write through these bindings, so a header key cannot drift between
7
+ * the encode and decode side. Domain field names live in the Vercel layer, not
8
+ * core, per the header-discipline rule.
9
+ */
10
+
11
+ import type * as AI from 'ai';
12
+
13
+ import { boolField, enumField, type HeaderField, jsonField, strField } from '../../core/codec/index.js';
14
+
15
+ /** Stream / message id (text & reasoning streams). */
16
+ export const fId = strField('id');
17
+ /**
18
+ * Provider metadata envelope, typed to the AI SDK shape. Annotated explicitly:
19
+ * the inferred type resolves to the AI SDK's internal `SharedV3ProviderMetadata`
20
+ * alias, which isn't portably nameable across the package boundary.
21
+ */
22
+ export const fMeta: HeaderField<AI.ProviderMetadata | undefined, 'providerMetadata'> = jsonField<
23
+ AI.ProviderMetadata,
24
+ 'providerMetadata'
25
+ >('providerMetadata');
26
+ /** Tool call id — defaulted to total: an absent header reads as `''`. */
27
+ export const fToolCallId = strField('toolCallId', '');
28
+ /** Tool name — defaulted to total. */
29
+ export const fToolName = strField('toolName', '');
30
+ /** Whether the tool is a dynamic tool. */
31
+ export const fDynamic = boolField('dynamic');
32
+ /** Optional human-readable title. */
33
+ export const fTitle = strField('title');
34
+ /** Whether the provider executed the tool. */
35
+ export const fProviderExecuted = boolField('providerExecuted');
36
+ /** Media type for file / source-document parts — defaulted to total. */
37
+ export const fMediaType = strField('mediaType', '');
38
+ /** Source id for source-url / source-document parts — defaulted to total. */
39
+ export const fSourceId = strField('sourceId', '');
40
+
41
+ // --- input-side bindings (shared by the input descriptors' encode/decode) ---
42
+
43
+ /** Domain message id (`message.id`) stamped on every user-message part — distinct from the wire codec-message-id transport header. */
44
+ export const fMessageId = strField('messageId');
45
+ /** Whether the user approved a tool execution — defaulted to total so an absent header reads `false`. */
46
+ export const fApproved = boolField('approved', false);
47
+ /** Optional human-readable reason on a tool-approval response. */
48
+ export const fReason = strField('reason');
49
+
50
+ /**
51
+ * Validated finish reason. Mirrors the AI SDK's `FinishReason` literals and
52
+ * falls back to `'stop'` for an absent or unrecognized value.
53
+ */
54
+ export const fFinishReason = enumField(
55
+ 'finishReason',
56
+ ['stop', 'length', 'content-filter', 'tool-calls', 'error', 'other'] as const,
57
+ 'stop',
58
+ );
@@ -0,0 +1,54 @@
1
+ /**
2
+ * File and source content-part folds: file / source-url / source-document.
3
+ * These are independent attachments — each appends a part, never dedups.
4
+ */
5
+
6
+ import type * as AI from 'ai';
7
+
8
+ import { stripUndefined } from '../../utils.js';
9
+ import { ensureMessage, type VercelProjection } from './reducer-state.js';
10
+
11
+ /**
12
+ * Fold a file or source content chunk into the projection.
13
+ * @param state - Projection to fold into.
14
+ * @param chunk - The file, source-url, or source-document chunk.
15
+ * @param messageId - The target codec-message-id.
16
+ * @returns The same projection reference.
17
+ */
18
+ export const foldContentPart = (
19
+ state: VercelProjection,
20
+ chunk: Extract<AI.UIMessageChunk, { type: 'file' | 'source-url' | 'source-document' }>,
21
+ messageId: string,
22
+ ): VercelProjection => {
23
+ const message = ensureMessage(state, messageId);
24
+
25
+ switch (chunk.type) {
26
+ case 'file': {
27
+ message.parts.push({ type: 'file', mediaType: chunk.mediaType, url: chunk.url });
28
+ return state;
29
+ }
30
+ case 'source-url': {
31
+ message.parts.push(
32
+ stripUndefined({
33
+ type: 'source-url' as const,
34
+ sourceId: chunk.sourceId,
35
+ url: chunk.url,
36
+ title: chunk.title,
37
+ }),
38
+ );
39
+ return state;
40
+ }
41
+ case 'source-document': {
42
+ message.parts.push(
43
+ stripUndefined({
44
+ type: 'source-document' as const,
45
+ sourceId: chunk.sourceId,
46
+ mediaType: chunk.mediaType,
47
+ title: chunk.title,
48
+ filename: chunk.filename,
49
+ }),
50
+ );
51
+ return state;
52
+ }
53
+ }
54
+ };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * data-* part folds. Transient data parts are dropped; persistent ones are
3
+ * appended, or replaced in place when a matching `id` is already present.
4
+ */
5
+
6
+ import type * as AI from 'ai';
7
+
8
+ import { stripUndefined } from '../../utils.js';
9
+ import { ensureMessage, type VercelProjection } from './reducer-state.js';
10
+
11
+ /**
12
+ * Fold a `data-*` chunk into the projection.
13
+ * @param state - Projection to fold into.
14
+ * @param chunk - The data-* chunk.
15
+ * @param messageId - The target codec-message-id.
16
+ * @returns The same projection reference.
17
+ */
18
+ export const foldDataPart = (
19
+ state: VercelProjection,
20
+ chunk: Extract<AI.UIMessageChunk, { type: `data-${string}` }>,
21
+ messageId: string,
22
+ ): VercelProjection => {
23
+ if (chunk.transient) return state;
24
+
25
+ const message = ensureMessage(state, messageId);
26
+
27
+ // CAST: chunk.type is `data-${string}` which satisfies DataUIPart, but
28
+ // TypeScript cannot verify the template literal matches a specific
29
+ // UIMessagePart variant at the type level.
30
+ const dataPart = stripUndefined({
31
+ type: chunk.type,
32
+ id: chunk.id,
33
+ data: chunk.data,
34
+ }) as AI.UIMessage['parts'][number];
35
+
36
+ if (chunk.id !== undefined) {
37
+ const idx = message.parts.findIndex((p) => p.type === chunk.type && 'id' in p && p.id === chunk.id);
38
+ if (idx !== -1) {
39
+ message.parts[idx] = dataPart;
40
+ return state;
41
+ }
42
+ }
43
+
44
+ message.parts.push(dataPart);
45
+ return state;
46
+ };
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Client-published input folds and the pending-resolution buffering.
3
+ *
4
+ * Tool resolutions (`ToolResult`, `ToolResultError`, `ToolApprovalResponse`)
5
+ * carry a `codecMessageId` targeting the assistant they amend. When that
6
+ * assistant (or its tool part) has not yet arrived, the resolution is buffered
7
+ * in `pendingToolResolutions` and {@link retryPendingResolutions} re-evaluates
8
+ * it after every subsequent fold.
9
+ */
10
+
11
+ import type * as AI from 'ai';
12
+
13
+ import type { ReducerMeta, ToolApprovalResponse, ToolResult, ToolResultError } from '../../core/codec/index.js';
14
+ import type {
15
+ VercelToolApprovalResponsePayload,
16
+ VercelToolResultErrorPayload,
17
+ VercelToolResultPayload,
18
+ } from './events.js';
19
+ import {
20
+ ensureTrackers,
21
+ getToolPart,
22
+ type OwnerLookup,
23
+ type PendingToolResolution,
24
+ type VercelProjection,
25
+ } from './reducer-state.js';
26
+ import { toolBase, transitionToolPart } from './tool-transitions.js';
27
+
28
+ /**
29
+ * Fold a user message into the projection, correlating on the wire
30
+ * codec-message-id (the caller's `message.id` is preserved verbatim). A
31
+ * multi-part user message fans out into one wire event per part, all sharing
32
+ * the codec-message-id — folding appends the incoming parts to the existing
33
+ * entry, reassembling the message part by part. The transport delivers each
34
+ * wire exactly once (its per-message version high-water-mark drops replays),
35
+ * so the merge sees every part once and stays consistent.
36
+ *
37
+ * Optimistic (serial-less) seeds need no special handling here: the transport
38
+ * refolds the node from its log when the echo's serial arrives, rebuilding the
39
+ * projection from a fresh `init` so the seed never coexists with its echo.
40
+ * @param state - Projection to fold into.
41
+ * @param message - The user message (or one decoded part of it) to add or merge.
42
+ * @param meta - Transport-derived metadata carrying the codec-message-id.
43
+ * @returns The same projection reference.
44
+ */
45
+ export const foldUserMessage = (
46
+ state: VercelProjection,
47
+ message: AI.UIMessage,
48
+ meta: ReducerMeta,
49
+ ): VercelProjection => {
50
+ // Correlate the projection entry on the wire codec-message-id; the
51
+ // caller-supplied `message.id` is preserved verbatim and surfaced to the
52
+ // application unchanged. Without a codec-message-id the message has no
53
+ // identity to key on, so it is appended as a fresh entry.
54
+ const codecMessageId = meta.messageId;
55
+ if (codecMessageId === undefined) {
56
+ state.messages.push({ codecMessageId: message.id, message });
57
+ return state;
58
+ }
59
+ const existing = state.messages.find((e) => e.codecMessageId === codecMessageId);
60
+ if (existing === undefined) {
61
+ state.messages.push({ codecMessageId, message });
62
+ } else {
63
+ // Merge by codec-message-id: keep the existing envelope (id and role are
64
+ // stamped identically on every part of one message) and append the
65
+ // incoming parts in fold order — wire serials preserve publish order.
66
+ existing.message.parts.push(...message.parts);
67
+ }
68
+ return state;
69
+ };
70
+
71
+ /**
72
+ * Fold a client-published `ToolResult`. The input carries
73
+ * `codecMessageId` pointing at the assistant whose `dynamic-tool` part
74
+ * holds the matching `toolCallId`. If the assistant and its matching
75
+ * `dynamic-tool` part are both present, fold directly; otherwise pend
76
+ * until that tool part arrives.
77
+ * @param state - Projection to fold into.
78
+ * @param event - The tool-result input (codecMessageId + domain payload).
79
+ * @returns The same projection reference.
80
+ */
81
+ export const foldClientToolResult = (
82
+ state: VercelProjection,
83
+ event: ToolResult<VercelToolResultPayload>,
84
+ ): VercelProjection => {
85
+ const { toolCallId, output } = event.payload;
86
+ return resolveOrPend(state, event.codecMessageId, toolCallId, { kind: 'tool-result', output });
87
+ };
88
+
89
+ /**
90
+ * Fold a client-published `ToolResultError`. Mirrors
91
+ * {@link foldClientToolResult} but with the error transition.
92
+ * @param state - Projection to fold into.
93
+ * @param event - The tool-result-error input (codecMessageId + domain payload).
94
+ * @returns The same projection reference.
95
+ */
96
+ export const foldClientToolResultError = (
97
+ state: VercelProjection,
98
+ event: ToolResultError<VercelToolResultErrorPayload>,
99
+ ): VercelProjection => {
100
+ const { toolCallId, message } = event.payload;
101
+ return resolveOrPend(state, event.codecMessageId, toolCallId, { kind: 'tool-result-error', message });
102
+ };
103
+
104
+ /**
105
+ * Fold a client-published `ToolApprovalResponse`. The input carries
106
+ * `codecMessageId` pointing at the assistant whose `dynamic-tool` part
107
+ * holds the matching `toolCallId`. Approval → `approval-responded`;
108
+ * denial → `output-denied` via {@link transitionToolPart}.
109
+ * @param state - Projection to fold into.
110
+ * @param event - The approval-response input.
111
+ * @returns The same projection reference.
112
+ */
113
+ export const foldToolApprovalResponse = (
114
+ state: VercelProjection,
115
+ event: ToolApprovalResponse<VercelToolApprovalResponsePayload>,
116
+ ): VercelProjection => {
117
+ const { toolCallId, approved, reason } = event.payload;
118
+ return resolveOrPend(state, event.codecMessageId, toolCallId, {
119
+ kind: 'tool-approval-response',
120
+ approved,
121
+ ...(reason === undefined ? {} : { reason }),
122
+ });
123
+ };
124
+
125
+ /**
126
+ * Apply a resolution when its tool part is present, otherwise buffer it in
127
+ * `pendingToolResolutions` for {@link retryPendingResolutions}.
128
+ * @param state - Projection to fold into.
129
+ * @param codecMessageId - The assistant the resolution targets.
130
+ * @param toolCallId - The tool call being resolved.
131
+ * @param resolution - The resolution variant to apply or buffer.
132
+ * @returns The same projection reference.
133
+ */
134
+ const resolveOrPend = (
135
+ state: VercelProjection,
136
+ codecMessageId: string,
137
+ toolCallId: string,
138
+ resolution: PendingToolResolution['resolution'],
139
+ ): VercelProjection => {
140
+ const owner = findOwner(state, codecMessageId, toolCallId);
141
+ if (owner) {
142
+ applyResolution(owner, toolCallId, resolution);
143
+ } else {
144
+ state.pendingToolResolutions.push({ targetCodecMessageId: codecMessageId, toolCallId, resolution });
145
+ }
146
+ return state;
147
+ };
148
+
149
+ /**
150
+ * Apply one tool resolution onto its located `dynamic-tool` part, replacing
151
+ * the part with the transitioned shape — the single application point shared
152
+ * by the direct folds and {@link retryPendingResolutions}.
153
+ * @param owner - The located owner (message + tracker + part).
154
+ * @param toolCallId - The tool call being resolved.
155
+ * @param resolution - The resolution variant to apply.
156
+ */
157
+ const applyResolution = (
158
+ owner: OwnerLookup,
159
+ toolCallId: string,
160
+ resolution: PendingToolResolution['resolution'],
161
+ ): void => {
162
+ switch (resolution.kind) {
163
+ case 'tool-result': {
164
+ owner.message.parts[owner.tracker.partIndex] = transitionToolPart(owner.part, {
165
+ type: 'tool-output-available',
166
+ toolCallId,
167
+ output: resolution.output,
168
+ });
169
+ break;
170
+ }
171
+ case 'tool-result-error': {
172
+ owner.message.parts[owner.tracker.partIndex] = transitionToolPart(owner.part, {
173
+ type: 'tool-output-error',
174
+ toolCallId,
175
+ errorText: resolution.message,
176
+ });
177
+ break;
178
+ }
179
+ case 'tool-approval-response': {
180
+ owner.message.parts[owner.tracker.partIndex] = approvalTransition(
181
+ owner.part,
182
+ resolution.approved,
183
+ resolution.reason,
184
+ );
185
+ break;
186
+ }
187
+ }
188
+ };
189
+
190
+ /**
191
+ * Re-attempt every pending tool resolution against the current projection.
192
+ * Successfully promoted entries are removed from the pending list. Cheap:
193
+ * bounded by the number of pending entries.
194
+ * @param state - Projection to walk and mutate.
195
+ */
196
+ export const retryPendingResolutions = (state: VercelProjection): void => {
197
+ const next: VercelProjection['pendingToolResolutions'] = [];
198
+ for (const pending of state.pendingToolResolutions) {
199
+ const owner = findOwner(state, pending.targetCodecMessageId, pending.toolCallId);
200
+ if (!owner) {
201
+ next.push(pending);
202
+ continue;
203
+ }
204
+ applyResolution(owner, pending.toolCallId, pending.resolution);
205
+ }
206
+ state.pendingToolResolutions = next;
207
+ };
208
+
209
+ const findOwner = (state: VercelProjection, codecMessageId: string, toolCallId: string): OwnerLookup | undefined => {
210
+ const entry = state.messages.find((e) => e.codecMessageId === codecMessageId);
211
+ if (!entry) return undefined;
212
+ const trackers = ensureTrackers(state, codecMessageId);
213
+ const found = getToolPart(entry.message, trackers, toolCallId);
214
+ if (!found) return undefined;
215
+ return { message: entry.message, tracker: found.tracker, part: found.part };
216
+ };
217
+
218
+ /**
219
+ * Build the next `dynamic-tool` part shape for an approval response.
220
+ *
221
+ * For `approved=true`, transition to `approval-responded` so the AI SDK's
222
+ * multi-step loop will auto-run the tool on the next step.
223
+ * `transitionToolPart` has no shape for this transition, so we synthesize
224
+ * the part directly.
225
+ *
226
+ * For `approved=false`, delegate to `transitionToolPart` with a synthetic
227
+ * `tool-output-denied` chunk so denial mirrors the chunk-driven path.
228
+ * @param part - The existing `dynamic-tool` part being transitioned.
229
+ * @param approved - Whether the user approved the tool execution.
230
+ * @param reason - Optional human-readable reason.
231
+ * @returns The replacement `dynamic-tool` part.
232
+ */
233
+ const approvalTransition = (
234
+ part: AI.DynamicToolUIPart,
235
+ approved: boolean,
236
+ reason: string | undefined,
237
+ ): AI.DynamicToolUIPart => {
238
+ if (approved) {
239
+ return {
240
+ ...toolBase(part),
241
+ state: 'approval-responded',
242
+ input: 'input' in part ? part.input : undefined,
243
+ approval: {
244
+ id: 'approval' in part && part.approval ? part.approval.id : '',
245
+ approved: true,
246
+ ...(reason === undefined ? {} : { reason }),
247
+ },
248
+ };
249
+ }
250
+ return transitionToolPart(part, {
251
+ type: 'tool-output-denied',
252
+ toolCallId: part.toolCallId,
253
+ ...(reason === undefined ? {} : { reason }),
254
+ });
255
+ };