@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,85 @@
1
+ /**
2
+ * Lifecycle chunk folds: start, start-step, finish-step, finish, abort,
3
+ * error, message-metadata.
4
+ */
5
+
6
+ import type * as AI from 'ai';
7
+
8
+ import { ensureMessage, type VercelProjection } from './reducer-state.js';
9
+
10
+ /**
11
+ * Set a message's metadata from a chunk when both the message exists and the
12
+ * chunk carries metadata. Shared by the `finish` and `message-metadata` cases,
13
+ * which apply it identically. The `start` case is not routed through here — it
14
+ * creates the message via `ensureMessage` first.
15
+ * @param state - Projection holding the message.
16
+ * @param messageId - The target codec-message-id.
17
+ * @param metadata - The chunk's `messageMetadata`, or undefined to leave it unchanged.
18
+ */
19
+ const applyMessageMetadata = (state: VercelProjection, messageId: string, metadata: AI.UIMessage['metadata']): void => {
20
+ if (metadata === undefined) return;
21
+ const message = state.messages.find((e) => e.codecMessageId === messageId)?.message;
22
+ if (message) message.metadata = metadata;
23
+ };
24
+
25
+ /**
26
+ * Fold a message-lifecycle chunk into the projection.
27
+ * @param state - Projection to fold into.
28
+ * @param chunk - The lifecycle chunk.
29
+ * @param messageId - The target codec-message-id.
30
+ * @returns The same projection reference.
31
+ */
32
+ export const foldLifecycle = (
33
+ state: VercelProjection,
34
+ chunk: Extract<
35
+ AI.UIMessageChunk,
36
+ { type: 'start' | 'start-step' | 'finish-step' | 'finish' | 'abort' | 'error' | 'message-metadata' }
37
+ >,
38
+ messageId: string,
39
+ ): VercelProjection => {
40
+ switch (chunk.type) {
41
+ case 'start': {
42
+ // The projection entry is keyed on the wire codec-message-id
43
+ // (`messageId`); every subsequent chunk for this message correlates on
44
+ // that, independent of `message.id`. So we faithfully reproduce the
45
+ // stream's own `messageId` on the reconstructed `UIMessage.id` (the
46
+ // value surfaced to the application) without risk of orphaning later
47
+ // chunks. When the stream omits it, the codec-message-id seeded by
48
+ // `ensureMessage` stands as the fallback id.
49
+ const message = ensureMessage(state, messageId);
50
+ if (chunk.messageId !== undefined) message.id = chunk.messageId;
51
+ if (chunk.messageMetadata !== undefined) message.metadata = chunk.messageMetadata;
52
+ return state;
53
+ }
54
+ case 'start-step': {
55
+ const message = ensureMessage(state, messageId);
56
+ message.parts.push({ type: 'step-start' });
57
+ return state;
58
+ }
59
+ case 'finish-step': {
60
+ // Reset text/reasoning stream trackers so a follow-up step can start
61
+ // new parts with potentially-reused stream ids.
62
+ const trackers = state.trackers.get(messageId);
63
+ if (trackers) {
64
+ trackers.text.clear();
65
+ trackers.reasoning.clear();
66
+ }
67
+ return state;
68
+ }
69
+ case 'finish': {
70
+ applyMessageMetadata(state, messageId, chunk.messageMetadata);
71
+ // Tracker state retained — late events still resolvable; cleanup happens at Run end.
72
+ return state;
73
+ }
74
+ case 'abort':
75
+ case 'error': {
76
+ // No state mutation — run termination is observed via the wire run-end
77
+ // event, not the projection.
78
+ return state;
79
+ }
80
+ case 'message-metadata': {
81
+ applyMessageMetadata(state, messageId, chunk.messageMetadata);
82
+ return state;
83
+ }
84
+ }
85
+ };
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Text and reasoning streaming folds: the {start, delta, end} lifecycle for
3
+ * both `text-*` and `reasoning-*` chunks, which share the same shape.
4
+ */
5
+
6
+ import type * as AI from 'ai';
7
+
8
+ import { ensureMessage, ensureTrackers, type VercelProjection } from './reducer-state.js';
9
+
10
+ /**
11
+ * Fold a text or reasoning streaming chunk into the projection.
12
+ * @param state - Projection to fold into.
13
+ * @param chunk - The text/reasoning start, delta, or end chunk.
14
+ * @param messageId - The target codec-message-id.
15
+ * @returns The same projection reference.
16
+ */
17
+ export const foldTextOrReasoning = (
18
+ state: VercelProjection,
19
+ chunk: Extract<
20
+ AI.UIMessageChunk,
21
+ { type: 'text-start' | 'text-delta' | 'text-end' | 'reasoning-start' | 'reasoning-delta' | 'reasoning-end' }
22
+ >,
23
+ messageId: string,
24
+ ): VercelProjection => {
25
+ const message = ensureMessage(state, messageId);
26
+ const trackers = ensureTrackers(state, messageId);
27
+
28
+ const isText = chunk.type.startsWith('text-');
29
+ const partType = isText ? 'text' : 'reasoning';
30
+ const activeMap = isText ? trackers.text : trackers.reasoning;
31
+
32
+ switch (chunk.type) {
33
+ case 'text-start':
34
+ case 'reasoning-start': {
35
+ activeMap.set(chunk.id, message.parts.length);
36
+ message.parts.push({ type: partType, text: '' });
37
+ return state;
38
+ }
39
+ case 'text-delta':
40
+ case 'reasoning-delta': {
41
+ const idx = activeMap.get(chunk.id);
42
+ if (idx === undefined) return state;
43
+ const part = message.parts[idx];
44
+ if (part?.type === partType) {
45
+ part.text += chunk.delta;
46
+ }
47
+ return state;
48
+ }
49
+ case 'text-end':
50
+ case 'reasoning-end': {
51
+ activeMap.delete(chunk.id);
52
+ return state;
53
+ }
54
+ }
55
+ };
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Tool-input streaming folds: tool-input-start / -delta / -available / -error.
3
+ * Tool deltas arrive as raw JSON fragments accumulated in the tracker's
4
+ * `inputText` buffer and parsed on each delta.
5
+ */
6
+
7
+ import type * as AI from 'ai';
8
+
9
+ import { parseJson } from '../../utils.js';
10
+ import { ensureMessage, ensureTrackers, getToolPart, type VercelProjection } from './reducer-state.js';
11
+ import { toolBase } from './tool-transitions.js';
12
+
13
+ /**
14
+ * Fold a tool-input streaming chunk into the projection.
15
+ * @param state - Projection to fold into.
16
+ * @param chunk - The tool-input start, delta, available, or error chunk.
17
+ * @param messageId - The target codec-message-id.
18
+ * @returns The same projection reference.
19
+ */
20
+ export const foldToolInput = (
21
+ state: VercelProjection,
22
+ chunk: Extract<
23
+ AI.UIMessageChunk,
24
+ { type: 'tool-input-start' | 'tool-input-delta' | 'tool-input-available' | 'tool-input-error' }
25
+ >,
26
+ messageId: string,
27
+ ): VercelProjection => {
28
+ const message = ensureMessage(state, messageId);
29
+ const trackers = ensureTrackers(state, messageId);
30
+
31
+ switch (chunk.type) {
32
+ case 'tool-input-start': {
33
+ const partIndex = message.parts.length;
34
+ message.parts.push({ ...toolBase(chunk), state: 'input-streaming', input: undefined });
35
+ trackers.tools.set(chunk.toolCallId, { partIndex, inputText: '' });
36
+ return state;
37
+ }
38
+ case 'tool-input-delta': {
39
+ const tracker = trackers.tools.get(chunk.toolCallId);
40
+ if (!tracker) return state;
41
+ tracker.inputText += chunk.inputTextDelta;
42
+
43
+ const parsedInput = parseJson(tracker.inputText);
44
+
45
+ const found = getToolPart(message, trackers, chunk.toolCallId);
46
+ if (!found) return state;
47
+ message.parts[found.tracker.partIndex] = {
48
+ ...toolBase(found.part),
49
+ state: 'input-streaming',
50
+ input: parsedInput,
51
+ };
52
+ return state;
53
+ }
54
+ case 'tool-input-available': {
55
+ const found = getToolPart(message, trackers, chunk.toolCallId);
56
+ if (!found) return state;
57
+ message.parts[found.tracker.partIndex] = {
58
+ ...toolBase(found.part),
59
+ state: 'input-available',
60
+ input: chunk.input,
61
+ };
62
+ return state;
63
+ }
64
+ case 'tool-input-error': {
65
+ const found = getToolPart(message, trackers, chunk.toolCallId);
66
+ if (found) {
67
+ message.parts[found.tracker.partIndex] = {
68
+ ...toolBase(found.part),
69
+ state: 'output-error',
70
+ input: chunk.input,
71
+ errorText: chunk.errorText,
72
+ };
73
+ } else {
74
+ const partIndex = message.parts.length;
75
+ message.parts.push({
76
+ ...toolBase(chunk),
77
+ state: 'output-error',
78
+ input: chunk.input,
79
+ errorText: chunk.errorText,
80
+ });
81
+ trackers.tools.set(chunk.toolCallId, { partIndex, inputText: '' });
82
+ }
83
+ return state;
84
+ }
85
+ }
86
+ };
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Agent-published tool-output transitions: tool-output-available /
3
+ * tool-output-error / tool-output-denied / tool-approval-request.
4
+ */
5
+
6
+ import type * as AI from 'ai';
7
+
8
+ import {
9
+ ensureMessage,
10
+ ensureTrackers,
11
+ getToolPart,
12
+ type OwnerLookup,
13
+ type VercelProjection,
14
+ } from './reducer-state.js';
15
+ import { transitionToolPart } from './tool-transitions.js';
16
+
17
+ /**
18
+ * Locate the `dynamic-tool` part for a `toolCallId` anywhere in the projection.
19
+ * Agent-emitted second-pass tool outputs (after an approved tool runs) are
20
+ * stamped with a fresh codec-message-id that differs from the assistant holding
21
+ * the tool call, so they can't be found via `meta.messageId` — they fold onto
22
+ * whichever message holds the matching tool call (created in the first pass or
23
+ * by an approval response).
24
+ * @param state - Projection to scan.
25
+ * @param toolCallId - The tool call to locate.
26
+ * @returns The owning message, tracker, and part, or `undefined` if absent.
27
+ */
28
+ const findToolPartOwner = (state: VercelProjection, toolCallId: string): OwnerLookup | undefined => {
29
+ for (const entry of state.messages) {
30
+ const trackers = state.trackers.get(entry.codecMessageId);
31
+ if (!trackers) continue;
32
+ const found = getToolPart(entry.message, trackers, toolCallId);
33
+ if (found) return { message: entry.message, tracker: found.tracker, part: found.part };
34
+ }
35
+ return undefined;
36
+ };
37
+
38
+ /**
39
+ * Fold an agent-published tool-output chunk into the projection.
40
+ * @param state - Projection to fold into.
41
+ * @param chunk - The tool-output-available/-error/-denied or tool-approval-request chunk.
42
+ * @param messageId - The target codec-message-id (used for the approval-request / denied paths).
43
+ * @returns The same projection reference.
44
+ */
45
+ export const foldToolOutput = (
46
+ state: VercelProjection,
47
+ chunk: Extract<
48
+ AI.UIMessageChunk,
49
+ { type: 'tool-output-available' | 'tool-output-error' | 'tool-output-denied' | 'tool-approval-request' }
50
+ >,
51
+ messageId: string,
52
+ ): VercelProjection => {
53
+ // `tool-output-available` / `tool-output-error` after an approved tool runs
54
+ // are emitted by streamText's continuation pass under a fresh
55
+ // codec-message-id that differs from the assistant holding the tool call.
56
+ // Resolve the owning part by toolCallId across the whole projection so the
57
+ // output folds onto the original message. Deliberately do NOT materialise
58
+ // `messageId` first — that would leave a phantom empty message behind the
59
+ // fresh id. Drop on miss: a tool output with no matching tool call has no
60
+ // anchor to attach to.
61
+ if (chunk.type === 'tool-output-available' || chunk.type === 'tool-output-error') {
62
+ const owner = findToolPartOwner(state, chunk.toolCallId);
63
+ if (!owner) return state;
64
+ owner.message.parts[owner.tracker.partIndex] = transitionToolPart(owner.part, chunk);
65
+ return state;
66
+ }
67
+
68
+ // `tool-approval-request` (first pass) creates the part on the run's own
69
+ // message; `tool-output-denied` transitions that same part. Both key on the
70
+ // stamped messageId.
71
+ const message = ensureMessage(state, messageId);
72
+ const trackers = ensureTrackers(state, messageId);
73
+
74
+ const found = getToolPart(message, trackers, chunk.toolCallId);
75
+ if (!found) return state;
76
+
77
+ message.parts[found.tracker.partIndex] = transitionToolPart(found.part, chunk);
78
+ return state;
79
+ };
@@ -1,10 +1,12 @@
1
1
  /**
2
- * Vercel AI SDK codec — implements
3
- * `Codec<VercelInput, VercelOutput, VercelProjection, UIMessage>`.
2
+ * Vercel AI SDK codec — `UIMessageCodec`.
4
3
  *
5
- * The codec is the reducer (extends `Reducer<VercelInput | VercelOutput,
6
- * VercelProjection>`) plus encoder/decoder factories and `getMessages`
7
- * for Tree population.
4
+ * Assembled by `defineCodec` from the codec's parts: the reducer
5
+ * (`init`/`fold`/`getMessages`), the declarative output and input descriptor
6
+ * tables (`outputs` / `inputs`, each a builder function `defineCodec` injects
7
+ * the direction-scoped builder into), and the decode lifecycle policy.
8
+ * `defineCodec` builds the generic encoder/decoder and merges the well-known
9
+ * input factories internally.
8
10
  *
9
11
  * ```ts
10
12
  * import { UIMessageCodec } from '@ably/ai-transport/vercel';
@@ -15,68 +17,26 @@
15
17
  * ```
16
18
  */
17
19
 
18
- import type * as AI from 'ai';
19
-
20
- import type { Codec } from '../../core/codec/types.js';
21
- import { createDecoder } from './decoder.js';
22
- import { createEncoder } from './encoder.js';
23
- import type {
24
- VercelInput,
25
- VercelOutput,
26
- VercelToolApprovalResponsePayload,
27
- VercelToolResultErrorPayload,
28
- VercelToolResultPayload,
29
- } from './events.js';
30
- import { fold, getMessages, init, type VercelProjection } from './reducer.js';
20
+ import { defineCodec } from '../../core/codec/index.js';
21
+ import { createVercelDecodeLifecycle } from './decode-lifecycle.js';
22
+ import type { VercelInput, VercelOutput } from './events.js';
23
+ import { inputs } from './inputs.js';
24
+ import { outputs } from './outputs.js';
25
+ import { fold, getMessages, init } from './reducer.js';
31
26
 
32
27
  /**
33
28
  * Vercel AI SDK codec implementing
34
- * `Codec<VercelInput, VercelOutput, VercelProjection, UIMessage>`.
35
- *
36
- * Folds `VercelInput`s and `VercelOutput`s into a `VercelProjection`
37
- * carrying `UIMessage[]`. Encoder and decoder factories handle the wire
38
- * mapping for both directions.
29
+ * `Codec<VercelInput, VercelOutput, VercelProjection, UIMessage>`. `VercelProjection`
30
+ * and `UIMessage` are inferred from the reducer.
39
31
  */
40
- const uiMessageCodecImpl = {
41
- // Internal field - picked up by registerAgent via AdapterTagHolder cast. Spec: AIT-CT1a3, AIT-ST1a3.
42
- adapterTag: 'vercel-ai-sdk-ui-message' as const,
43
- init,
44
- fold,
45
- createEncoder,
46
- createDecoder,
47
- getMessages,
48
- createUserMessage: (message: AI.UIMessage): VercelInput => ({ kind: 'user-message', message }),
49
- createRegenerate: (target: string, parent: string): VercelInput => ({
50
- kind: 'regenerate',
51
- target,
52
- parent,
53
- }),
54
- createToolResult: (codecMessageId: string, payload: VercelToolResultPayload): VercelInput => ({
55
- kind: 'tool-result',
56
- codecMessageId,
57
- payload,
58
- }),
59
- createToolResultError: (codecMessageId: string, payload: VercelToolResultErrorPayload): VercelInput => ({
60
- kind: 'tool-result-error',
61
- codecMessageId,
62
- payload,
63
- }),
64
- createToolApprovalResponse: (codecMessageId: string, payload: VercelToolApprovalResponsePayload): VercelInput => ({
65
- kind: 'tool-approval-response',
66
- codecMessageId,
67
- payload,
68
- }),
69
- };
70
-
71
- // Validate Codec conformance via `satisfies` on the variable (no excess-property
72
- // check, so the internal `adapterTag` is permitted) while keeping the concrete
73
- // type so the codec-specific factories (createToolResult, etc.) stay callable.
74
- export const UIMessageCodec = uiMessageCodecImpl satisfies Codec<
75
- VercelInput,
76
- VercelOutput,
77
- VercelProjection,
78
- AI.UIMessage
79
- >;
32
+ export const UIMessageCodec = defineCodec<VercelInput, VercelOutput>()({
33
+ // Spec: AIT-CT1a3, AIT-ST1a3 registers this codec as an Ably agent.
34
+ adapterTag: 'vercel-ai-sdk-ui-message',
35
+ reducer: { init, fold, getMessages },
36
+ output: outputs,
37
+ input: inputs,
38
+ decodeLifecycle: createVercelDecodeLifecycle,
39
+ });
80
40
 
81
41
  export type { VercelInput, VercelOutput } from './events.js';
82
42
  export { type VercelProjection } from './reducer.js';
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Vercel input (`ai-input`) descriptors — the single source of truth for the
3
+ * `VercelInput` wire mapping, the user-message fan-out included.
4
+ *
5
+ * `defineCodec` injects the direction-scoped `{ event, batch }` builder; the
6
+ * generic input drivers consume the returned array. The tool inputs are single
7
+ * `event`s lensed onto their nested `payload`; `regenerate` is a wire-only
8
+ * signal; the multi-part user message is a `batch` that fans each
9
+ * `UIMessage` part out into one wire event (reassembled by the reducer).
10
+ *
11
+ * Author-facing acceptance gate: the injected `event`/`batch` builders narrow
12
+ * each member, so every `data` / `fields` / `parts` / `assemble` callback is
13
+ * fully typed. The file's single `as` cast is the wire trust boundary on the
14
+ * inbound role header (see `assemble`).
15
+ */
16
+
17
+ import type * as AI from 'ai';
18
+
19
+ import { HEADER_ROLE } from '../../constants.js';
20
+ import type { InputBuilder, InputDescriptor } from '../../core/codec/index.js';
21
+ import type { VercelInput } from './events.js';
22
+ import { fApproved, fId, fMediaType, fMessageId, fReason, fToolCallId } from './fields.js';
23
+ import { asString, isClientToolResultErrorWireData, isToolOutputAvailableWireData } from './wire-data.js';
24
+
25
+ /** Fallback for a message with no encodable parts (see the `user-message` batch). */
26
+ const EMPTY_MESSAGE_PARTS: AI.UIMessage['parts'] = [{ type: 'text', text: '' }];
27
+
28
+ /**
29
+ * Part types the `user-message` batch's `parts` sub-table can encode — must
30
+ * stay in step with that table. Parts outside this set (e.g. `step-start`,
31
+ * tool parts) have no wire mapping; `explode` filters them so the batch always
32
+ * yields at least one encodable part and the message round-trips.
33
+ * @param part - The UIMessage part to test.
34
+ * @returns Whether the part has a wire mapping in the batch's part table.
35
+ */
36
+ const isEncodablePart = (part: AI.UIMessage['parts'][number]): boolean =>
37
+ part.type === 'text' || part.type === 'file' || part.type.startsWith('data-');
38
+
39
+ /**
40
+ * The Vercel codec's `ai-input` descriptors, built from the injected
41
+ * direction-scoped builder.
42
+ * @param builder - The `{ event, batch }` builder curried on `VercelInput`.
43
+ * @param builder.event - Define a single-event input (payload-nested, or `wireOnly`).
44
+ * @param builder.batch - Define a multi-part (batch) input that fans out into one wire event per part.
45
+ * @returns The input descriptor table the generic input drivers consume.
46
+ */
47
+ export const inputs = ({ event, batch }: InputBuilder<VercelInput>): readonly InputDescriptor<VercelInput>[] => [
48
+ // --- tool inputs: nested payload, codec-message-id-addressed ----------------
49
+
50
+ event('tool-result', {
51
+ fields: [fToolCallId],
52
+ data: {
53
+ encode: (p) => ({ output: p.output }),
54
+ // Malformed wire data decodes to undefined, which the rebuild seam strips
55
+ // — the folded payload then has no `output` key (reads as undefined).
56
+ decode: (d) => ({ output: isToolOutputAvailableWireData(d) ? d.output : undefined }),
57
+ },
58
+ }),
59
+ event('tool-result-error', {
60
+ fields: [fToolCallId],
61
+ data: {
62
+ encode: (p) => ({ message: p.message }),
63
+ decode: (d) => ({ message: isClientToolResultErrorWireData(d) ? (d.message ?? '') : '' }),
64
+ },
65
+ }),
66
+ event('tool-approval-response', { fields: [fToolCallId, fApproved, fReason] }),
67
+
68
+ // --- wire-only signal -------------------------------------------------------
69
+
70
+ // `regenerate` carries no domain payload; `parent` / `target` ride the
71
+ // transport headers built by the client-session and read by the agent's
72
+ // input-event lookup, so it stamps only the `kind` header and decodes to [].
73
+ event('regenerate', { wireOnly: true }),
74
+
75
+ // --- multi-part client message ----------------------------------------------
76
+
77
+ // The user message fans out into one wire event per part, all sharing the
78
+ // `user-message` kind and codec-message-id, each carrying its `partType`. The
79
+ // message id (a codec header) and role (a transport header) are per-message,
80
+ // stamped on every part so the decode side can rebuild the envelope from any
81
+ // one; the reducer merges parts sharing a codec-message-id.
82
+ batch('user-message', {
83
+ // A message with no encodable parts (empty, or only unmapped types like
84
+ // step-start) still publishes one empty text part, so the codec-message-id
85
+ // and role survive and it round-trips to a one-part message. The driver's
86
+ // bare-headers fallback cannot round-trip (it carries no partType), so the
87
+ // ≥1-encodable-part guarantee lives here.
88
+ explode: (input) => {
89
+ const encodable = input.message.parts.filter((part) => isEncodablePart(part));
90
+ return encodable.length > 0 ? encodable : EMPTY_MESSAGE_PARTS;
91
+ },
92
+ partTypeOf: (part) => part.type,
93
+ parts: (p) => [
94
+ p('text', { data: { encode: (x) => x.text, decode: (d) => ({ text: asString(d) }) } }),
95
+ p('file', {
96
+ fields: [fMediaType],
97
+ data: { encode: (x) => x.url, decode: (d) => ({ url: asString(d) }) },
98
+ }),
99
+ p('data-*', {
100
+ fields: [fId],
101
+ data: { encode: (x) => x.data, decode: (d) => ({ data: d }) },
102
+ }),
103
+ ],
104
+ messageHeaders: (input) => {
105
+ const codecHeaders: Record<string, string> = {};
106
+ fMessageId.write(codecHeaders, input.message.id);
107
+ return { codecHeaders, transportHeaders: { [HEADER_ROLE]: input.message.role } };
108
+ },
109
+ assemble: (part, { codecHeaders, transportHeaders }) => {
110
+ // CAST: HEADER_ROLE is wire data; the role string is trusted as a UIMessage role.
111
+ const role = (transportHeaders[HEADER_ROLE] ?? 'user') as AI.UIMessage['role'];
112
+ const id = fMessageId.read(codecHeaders) ?? '';
113
+ return { message: { id, role, parts: [part] } };
114
+ },
115
+ }),
116
+ ];