@convex-dev/agent 0.6.4 → 0.7.0-alpha.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 (225) hide show
  1. package/MIGRATION.md +60 -116
  2. package/dist/client/messages.d.ts +1 -398
  3. package/dist/client/messages.d.ts.map +1 -1
  4. package/dist/client/messages.js +3 -47
  5. package/dist/client/messages.js.map +1 -1
  6. package/dist/client/types.d.ts +1 -455
  7. package/dist/client/types.d.ts.map +1 -1
  8. package/dist/component/_generated/component.d.ts +805 -45
  9. package/dist/component/_generated/component.d.ts.map +1 -1
  10. package/dist/component/messages.d.ts +1252 -201
  11. package/dist/component/messages.d.ts.map +1 -1
  12. package/dist/component/messages.js +49 -23
  13. package/dist/component/messages.js.map +1 -1
  14. package/dist/component/schema.d.ts +2593 -332
  15. package/dist/component/schema.d.ts.map +1 -1
  16. package/dist/component/streams.d.ts +7 -1
  17. package/dist/component/streams.d.ts.map +1 -1
  18. package/dist/component/streams.js +24 -30
  19. package/dist/component/streams.js.map +1 -1
  20. package/dist/component/threads.d.ts.map +1 -1
  21. package/dist/component/threads.js +1 -2
  22. package/dist/component/threads.js.map +1 -1
  23. package/dist/react/index.d.ts +5 -5
  24. package/dist/react/index.d.ts.map +1 -1
  25. package/dist/react/index.js +5 -5
  26. package/dist/react/index.js.map +1 -1
  27. package/dist/shared.d.ts +19 -7
  28. package/dist/shared.d.ts.map +1 -1
  29. package/dist/shared.js +4 -4
  30. package/dist/shared.js.map +1 -1
  31. package/dist/streaming/materializePersistedUIMessageChunks.d.ts +16 -0
  32. package/dist/streaming/materializePersistedUIMessageChunks.d.ts.map +1 -0
  33. package/dist/streaming/materializePersistedUIMessageChunks.js +302 -0
  34. package/dist/streaming/materializePersistedUIMessageChunks.js.map +1 -0
  35. package/dist/streaming/persistedUIMessageChunks.d.ts +106 -0
  36. package/dist/streaming/persistedUIMessageChunks.d.ts.map +1 -0
  37. package/dist/streaming/persistedUIMessageChunks.js +475 -0
  38. package/dist/streaming/persistedUIMessageChunks.js.map +1 -0
  39. package/dist/validators.d.ts +10026 -1055
  40. package/dist/validators.d.ts.map +1 -1
  41. package/dist/validators.js +71 -9
  42. package/dist/validators.js.map +1 -1
  43. package/dist/{UIMessages.d.ts → vercel/UIMessages.d.ts} +1 -1
  44. package/dist/vercel/UIMessages.d.ts.map +1 -0
  45. package/dist/{UIMessages.js → vercel/UIMessages.js} +63 -1
  46. package/dist/vercel/UIMessages.js.map +1 -0
  47. package/dist/{client → vercel/client}/createTool.d.ts +23 -16
  48. package/dist/vercel/client/createTool.d.ts.map +1 -0
  49. package/dist/{client → vercel/client}/createTool.js +2 -0
  50. package/dist/vercel/client/createTool.js.map +1 -0
  51. package/dist/{client → vercel/client}/definePlaygroundAPI.d.ts +533 -79
  52. package/dist/{client → vercel/client}/definePlaygroundAPI.d.ts.map +1 -1
  53. package/dist/{client → vercel/client}/definePlaygroundAPI.js +8 -7
  54. package/dist/vercel/client/definePlaygroundAPI.js.map +1 -0
  55. package/dist/{client → vercel/client}/files.d.ts +2 -2
  56. package/dist/vercel/client/files.d.ts.map +1 -0
  57. package/dist/vercel/client/files.js.map +1 -0
  58. package/dist/vercel/client/messages.d.ts +542 -0
  59. package/dist/vercel/client/messages.d.ts.map +1 -0
  60. package/dist/vercel/client/messages.js +66 -0
  61. package/dist/vercel/client/messages.js.map +1 -0
  62. package/dist/{client → vercel/client}/mockModel.d.ts +17 -17
  63. package/dist/vercel/client/mockModel.d.ts.map +1 -0
  64. package/dist/{client → vercel/client}/mockModel.js +12 -13
  65. package/dist/vercel/client/mockModel.js.map +1 -0
  66. package/dist/{client → vercel/client}/saveInputMessages.d.ts +2 -2
  67. package/dist/vercel/client/saveInputMessages.d.ts.map +1 -0
  68. package/dist/vercel/client/saveInputMessages.js.map +1 -0
  69. package/dist/{client → vercel/client}/search.d.ts +119 -22
  70. package/dist/vercel/client/search.d.ts.map +1 -0
  71. package/dist/{client → vercel/client}/search.js +2 -2
  72. package/dist/vercel/client/search.js.map +1 -0
  73. package/dist/{client → vercel/client}/start.d.ts +26 -11
  74. package/dist/vercel/client/start.d.ts.map +1 -0
  75. package/dist/{client → vercel/client}/start.js +64 -22
  76. package/dist/vercel/client/start.js.map +1 -0
  77. package/dist/{client → vercel/client}/streamText.d.ts +13 -12
  78. package/dist/vercel/client/streamText.d.ts.map +1 -0
  79. package/dist/{client → vercel/client}/streamText.js +43 -9
  80. package/dist/vercel/client/streamText.js.map +1 -0
  81. package/dist/{client → vercel/client}/streaming.d.ts +1410 -183
  82. package/dist/vercel/client/streaming.d.ts.map +1 -0
  83. package/dist/{client → vercel/client}/streaming.js +120 -52
  84. package/dist/vercel/client/streaming.js.map +1 -0
  85. package/dist/vercel/client/types.d.ts +456 -0
  86. package/dist/vercel/client/types.d.ts.map +1 -0
  87. package/dist/vercel/client/types.js +3 -0
  88. package/dist/vercel/client/types.js.map +1 -0
  89. package/dist/{client → vercel/client}/utils.d.ts +3 -2
  90. package/dist/vercel/client/utils.d.ts.map +1 -0
  91. package/dist/{client → vercel/client}/utils.js +2 -2
  92. package/dist/vercel/client/utils.js.map +1 -0
  93. package/dist/{deltas.d.ts → vercel/deltas.d.ts} +3 -4
  94. package/dist/vercel/deltas.d.ts.map +1 -0
  95. package/dist/{deltas.js → vercel/deltas.js} +195 -99
  96. package/dist/vercel/deltas.js.map +1 -0
  97. package/dist/{client → vercel}/index.d.ts +482 -106
  98. package/dist/vercel/index.d.ts.map +1 -0
  99. package/dist/{client → vercel}/index.js +64 -46
  100. package/dist/vercel/index.js.map +1 -0
  101. package/dist/{mapping.d.ts → vercel/mapping.d.ts} +5 -19
  102. package/dist/vercel/mapping.d.ts.map +1 -0
  103. package/dist/{mapping.js → vercel/mapping.js} +411 -114
  104. package/dist/vercel/mapping.js.map +1 -0
  105. package/dist/{react → vercel/react}/optimisticallySendMessage.d.ts +2 -2
  106. package/dist/vercel/react/optimisticallySendMessage.d.ts.map +1 -0
  107. package/dist/vercel/react/optimisticallySendMessage.js.map +1 -0
  108. package/dist/{react → vercel/react}/types.d.ts +1 -1
  109. package/dist/vercel/react/types.d.ts.map +1 -0
  110. package/dist/{react → vercel/react}/types.js.map +1 -1
  111. package/dist/{react → vercel/react}/useDeltaStreams.d.ts +1 -1
  112. package/dist/vercel/react/useDeltaStreams.d.ts.map +1 -0
  113. package/dist/{react → vercel/react}/useDeltaStreams.js +1 -1
  114. package/dist/vercel/react/useDeltaStreams.js.map +1 -0
  115. package/dist/{react → vercel/react}/useStreamingUIMessages.d.ts +1 -1
  116. package/dist/vercel/react/useStreamingUIMessages.d.ts.map +1 -0
  117. package/dist/{react → vercel/react}/useStreamingUIMessages.js +7 -3
  118. package/dist/vercel/react/useStreamingUIMessages.js.map +1 -0
  119. package/dist/{react → vercel/react}/useThreadMessages.d.ts +1 -1
  120. package/dist/vercel/react/useThreadMessages.d.ts.map +1 -0
  121. package/dist/{react → vercel/react}/useThreadMessages.js +1 -1
  122. package/dist/vercel/react/useThreadMessages.js.map +1 -0
  123. package/dist/{react → vercel/react}/useUIMessages.d.ts +1 -1
  124. package/dist/vercel/react/useUIMessages.d.ts.map +1 -0
  125. package/dist/{react → vercel/react}/useUIMessages.js +1 -1
  126. package/dist/vercel/react/useUIMessages.js.map +1 -0
  127. package/package.json +29 -22
  128. package/src/client/messages.ts +5 -107
  129. package/src/client/types.ts +0 -608
  130. package/src/component/_generated/component.ts +1089 -48
  131. package/src/component/messages.test.ts +273 -1
  132. package/src/component/messages.ts +62 -32
  133. package/src/component/streams.ts +47 -47
  134. package/src/component/threads.ts +5 -2
  135. package/src/eslintConfig.test.ts +44 -0
  136. package/src/react/index.ts +8 -5
  137. package/src/shared.ts +26 -36
  138. package/src/streaming/materializePersistedUIMessageChunks.test.ts +513 -0
  139. package/src/streaming/materializePersistedUIMessageChunks.ts +398 -0
  140. package/src/streaming/persistedUIMessageChunks.test.ts +205 -0
  141. package/src/streaming/persistedUIMessageChunks.ts +658 -0
  142. package/src/validators.test.ts +4 -1
  143. package/src/validators.ts +89 -8
  144. package/src/{UIMessages.combineUIMessages.test.ts → vercel/UIMessages.combineUIMessages.test.ts} +4 -1
  145. package/src/{UIMessages.ts → vercel/UIMessages.ts} +95 -3
  146. package/src/{client → vercel/client}/approval.test.ts +23 -13
  147. package/src/{client → vercel/client}/createTool.ts +62 -22
  148. package/src/{client → vercel/client}/definePlaygroundAPI.ts +15 -16
  149. package/src/{client → vercel/client}/files.ts +2 -2
  150. package/src/{client → vercel/client}/index.test.ts +99 -31
  151. package/src/vercel/client/messages.ts +182 -0
  152. package/src/{client → vercel/client}/mockModel.ts +38 -39
  153. package/src/{client → vercel/client}/saveInputMessages.test.ts +1 -1
  154. package/src/{client → vercel/client}/saveInputMessages.ts +3 -3
  155. package/src/{client → vercel/client}/search.test.ts +3 -3
  156. package/src/{client → vercel/client}/search.ts +4 -4
  157. package/src/{client → vercel/client}/setup.test.ts +6 -3
  158. package/src/vercel/client/start.test.ts +14 -0
  159. package/src/{client → vercel/client}/start.ts +147 -36
  160. package/src/vercel/client/streamText.test.ts +223 -0
  161. package/src/{client → vercel/client}/streamText.ts +102 -31
  162. package/src/{client → vercel/client}/streaming.integration.test.ts +6 -3
  163. package/src/{client → vercel/client}/streaming.test.ts +178 -4
  164. package/src/{client → vercel/client}/streaming.ts +114 -52
  165. package/src/vercel/client/types.test.ts +13 -0
  166. package/src/vercel/client/types.ts +679 -0
  167. package/src/{client → vercel/client}/utils.ts +13 -7
  168. package/src/{deltas.test.ts → vercel/deltas.test.ts} +384 -93
  169. package/src/{deltas.ts → vercel/deltas.ts} +218 -115
  170. package/src/{fromUIMessages.test.ts → vercel/fromUIMessages.test.ts} +17 -1
  171. package/src/{client → vercel}/index.ts +167 -84
  172. package/src/{mapping.test.ts → vercel/mapping.test.ts} +377 -92
  173. package/src/{mapping.ts → vercel/mapping.ts} +501 -137
  174. package/src/{react → vercel/react}/optimisticallySendMessage.ts +2 -2
  175. package/src/{react → vercel/react}/types.ts +1 -1
  176. package/src/{react → vercel/react}/useDeltaStreams.ts +6 -2
  177. package/src/{react → vercel/react}/useStreamingUIMessages.ts +7 -3
  178. package/src/{react → vercel/react}/useThreadMessages.ts +2 -2
  179. package/src/{react → vercel/react}/useUIMessages.ts +2 -2
  180. package/src/{toUIMessages.test.ts → vercel/toUIMessages.test.ts} +49 -2
  181. package/dist/UIMessages.d.ts.map +0 -1
  182. package/dist/UIMessages.js.map +0 -1
  183. package/dist/client/createTool.d.ts.map +0 -1
  184. package/dist/client/createTool.js.map +0 -1
  185. package/dist/client/definePlaygroundAPI.js.map +0 -1
  186. package/dist/client/files.d.ts.map +0 -1
  187. package/dist/client/files.js.map +0 -1
  188. package/dist/client/index.d.ts.map +0 -1
  189. package/dist/client/index.js.map +0 -1
  190. package/dist/client/mockModel.d.ts.map +0 -1
  191. package/dist/client/mockModel.js.map +0 -1
  192. package/dist/client/saveInputMessages.d.ts.map +0 -1
  193. package/dist/client/saveInputMessages.js.map +0 -1
  194. package/dist/client/search.d.ts.map +0 -1
  195. package/dist/client/search.js.map +0 -1
  196. package/dist/client/start.d.ts.map +0 -1
  197. package/dist/client/start.js.map +0 -1
  198. package/dist/client/streamText.d.ts.map +0 -1
  199. package/dist/client/streamText.js.map +0 -1
  200. package/dist/client/streaming.d.ts.map +0 -1
  201. package/dist/client/streaming.js.map +0 -1
  202. package/dist/client/utils.d.ts.map +0 -1
  203. package/dist/client/utils.js.map +0 -1
  204. package/dist/deltas.d.ts.map +0 -1
  205. package/dist/deltas.js.map +0 -1
  206. package/dist/mapping.d.ts.map +0 -1
  207. package/dist/mapping.js.map +0 -1
  208. package/dist/react/optimisticallySendMessage.d.ts.map +0 -1
  209. package/dist/react/optimisticallySendMessage.js.map +0 -1
  210. package/dist/react/types.d.ts.map +0 -1
  211. package/dist/react/useDeltaStreams.d.ts.map +0 -1
  212. package/dist/react/useDeltaStreams.js.map +0 -1
  213. package/dist/react/useStreamingUIMessages.d.ts.map +0 -1
  214. package/dist/react/useStreamingUIMessages.js.map +0 -1
  215. package/dist/react/useThreadMessages.d.ts.map +0 -1
  216. package/dist/react/useThreadMessages.js.map +0 -1
  217. package/dist/react/useUIMessages.d.ts.map +0 -1
  218. package/dist/react/useUIMessages.js.map +0 -1
  219. package/src/client/streamText.test.ts +0 -102
  220. /package/dist/{client → vercel/client}/files.js +0 -0
  221. /package/dist/{client → vercel/client}/saveInputMessages.js +0 -0
  222. /package/dist/{react → vercel/react}/optimisticallySendMessage.js +0 -0
  223. /package/dist/{react → vercel/react}/types.js +0 -0
  224. /package/src/{UIMessages.test.ts → vercel/UIMessages.test.ts} +0 -0
  225. /package/src/{react → vercel/react}/useUIMessages.test.ts +0 -0
@@ -0,0 +1,398 @@
1
+ import {
2
+ vToolResultOutput,
3
+ type Message,
4
+ type MessageContentParts,
5
+ type MessageWithMetadataInternal,
6
+ type StreamDelta,
7
+ type StreamMessage,
8
+ } from "../validators.js";
9
+ import { validate } from "convex-helpers/validators";
10
+ import {
11
+ collectContiguousStreamParts,
12
+ createPersistedUIMessageChunkState,
13
+ PersistedUIMessageChunkError,
14
+ reducePersistedUIMessageChunks,
15
+ setParsedToolInput,
16
+ type PersistedSourcePart,
17
+ type PersistedToolPart,
18
+ type PersistedUIMessagePart,
19
+ } from "./persistedUIMessageChunks.js";
20
+
21
+ type AssistantContentPart = Exclude<
22
+ Extract<Message, { role: "assistant" }>["content"],
23
+ string
24
+ >[number];
25
+ type ToolContentPart = Extract<Message, { role: "tool" }>["content"][number];
26
+ export type PersistedStreamMetadata = {
27
+ status: "success" | "failed";
28
+ error?: string;
29
+ };
30
+
31
+ /** Projects persisted UI-message chunks into durable component messages. */
32
+ export function projectPersistedUIMessageChunks(
33
+ stream: StreamMessage,
34
+ chunks: readonly unknown[],
35
+ metadata: PersistedStreamMetadata,
36
+ ): MessageWithMetadataInternal[] {
37
+ if (stream.format !== "UIMessageChunk") {
38
+ throw new Error(
39
+ `projectPersistedUIMessageChunks: unsupported stream format "${stream.format ?? "text"}" for stream ${stream.streamId}`,
40
+ );
41
+ }
42
+ validatePersistedUIMessageChunkIds(stream.streamId, chunks);
43
+ let reduced;
44
+ try {
45
+ reduced = reducePersistedUIMessageChunks(
46
+ createPersistedUIMessageChunkState(),
47
+ chunks,
48
+ );
49
+ } catch (error) {
50
+ throw persistedProjectionError(error);
51
+ }
52
+ for (const toolCallId of reduced.touchedToolCallIds) {
53
+ setParsedToolInput(
54
+ reduced.state,
55
+ toolCallId,
56
+ parseCompleteJson(reduced.state.toolInputText.get(toolCallId) ?? ""),
57
+ );
58
+ }
59
+ return projectPersistedUIMessageChunkParts(
60
+ reduced.state.parts,
61
+ stream,
62
+ metadata,
63
+ );
64
+ }
65
+
66
+ export function getPersistedUIMessageChunkParts(
67
+ deltas: readonly StreamDelta[],
68
+ fromCursor = 0,
69
+ ): { parts: unknown[]; cursor: number } {
70
+ return collectContiguousStreamParts(deltas, fromCursor);
71
+ }
72
+
73
+ export function validatePersistedUIMessageChunkIds(
74
+ streamId: string,
75
+ chunks: readonly unknown[],
76
+ ) {
77
+ for (const value of chunks) {
78
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
79
+ const chunk = value as Record<string, unknown>;
80
+ if (
81
+ chunk.type === "start" &&
82
+ typeof chunk.messageId === "string" &&
83
+ chunk.messageId !== `stream:${streamId}`
84
+ ) {
85
+ throw new Error("Expecting to only make one UIMessage in a stream");
86
+ }
87
+ }
88
+ }
89
+
90
+ export function projectPersistedUIMessageChunkParts(
91
+ parts: PersistedUIMessagePart[],
92
+ stream: StreamMessage,
93
+ metadata: PersistedStreamMetadata,
94
+ ): MessageWithMetadataInternal[] {
95
+ const blocks: PersistedUIMessagePart[][] = [];
96
+ let block: PersistedUIMessagePart[] = [];
97
+ const flush = () => {
98
+ if (block.length > 0) blocks.push(block);
99
+ block = [];
100
+ };
101
+ for (const part of parts) {
102
+ if (part.type === "step-start") {
103
+ flush();
104
+ } else if (
105
+ part.type === "text" ||
106
+ part.type === "reasoning" ||
107
+ part.type === "file" ||
108
+ part.type === "reasoning-file" ||
109
+ part.type === "custom" ||
110
+ part.type === "source-url" ||
111
+ part.type === "source-document" ||
112
+ isToolPart(part) ||
113
+ part.type.startsWith("data-")
114
+ ) {
115
+ block.push(part);
116
+ }
117
+ }
118
+ flush();
119
+
120
+ const messages: Array<{
121
+ message: Message;
122
+ sources: ReturnType<typeof projectSources>;
123
+ }> = [];
124
+ for (const current of blocks) {
125
+ const sources = projectSources(current);
126
+ const assistantContent: AssistantContentPart[] = [];
127
+ const tools = current.filter(isToolPart);
128
+ for (const part of current) {
129
+ if (part.type === "text") {
130
+ assistantContent.push({
131
+ type: "text",
132
+ text: part.text,
133
+ ...(part.providerMetadata
134
+ ? { providerOptions: part.providerMetadata }
135
+ : {}),
136
+ });
137
+ } else if (part.type === "reasoning") {
138
+ assistantContent.push({
139
+ type: "reasoning",
140
+ text: part.text,
141
+ ...(part.providerMetadata
142
+ ? { providerOptions: part.providerMetadata }
143
+ : {}),
144
+ });
145
+ } else if (part.type === "file") {
146
+ assistantContent.push({
147
+ type: "file",
148
+ data: part.url,
149
+ ...(part.filename ? { filename: part.filename } : {}),
150
+ mediaType: part.mediaType,
151
+ ...(part.providerMetadata
152
+ ? { providerOptions: part.providerMetadata }
153
+ : {}),
154
+ });
155
+ } else if (part.type === "reasoning-file") {
156
+ assistantContent.push({
157
+ type: "reasoning-file",
158
+ url: part.url,
159
+ mediaType: part.mediaType,
160
+ ...(part.providerMetadata
161
+ ? { providerOptions: part.providerMetadata }
162
+ : {}),
163
+ });
164
+ } else if (part.type === "custom") {
165
+ assistantContent.push({
166
+ type: "custom",
167
+ kind: part.kind,
168
+ ...(part.providerMetadata
169
+ ? { providerOptions: part.providerMetadata }
170
+ : {}),
171
+ });
172
+ } else if (isToolPart(part) && part.state !== "input-streaming") {
173
+ const input =
174
+ part.state === "output-error"
175
+ ? (part.input ?? part.rawInput ?? {})
176
+ : (part.input ?? {});
177
+ assistantContent.push({
178
+ type: "tool-call",
179
+ toolCallId: part.toolCallId,
180
+ toolName: getToolName(part),
181
+ input,
182
+ args: input,
183
+ providerExecuted: part.providerExecuted,
184
+ ...(part.title ? { title: part.title } : {}),
185
+ ...(part.toolMetadata ? { toolMetadata: part.toolMetadata } : {}),
186
+ ...(part.callProviderMetadata
187
+ ? { providerOptions: part.callProviderMetadata }
188
+ : {}),
189
+ });
190
+ if (part.approval) {
191
+ assistantContent.push({
192
+ type: "tool-approval-request",
193
+ approvalId: part.approval.id,
194
+ toolCallId: part.toolCallId,
195
+ ...(part.approval.isAutomatic !== undefined
196
+ ? { isAutomatic: part.approval.isAutomatic }
197
+ : {}),
198
+ ...(part.approval.signature
199
+ ? { signature: part.approval.signature }
200
+ : {}),
201
+ });
202
+ }
203
+ if (
204
+ part.providerExecuted === true &&
205
+ part.state !== "approval-responded" &&
206
+ (part.state === "output-available" || part.state === "output-error")
207
+ ) {
208
+ assistantContent.push(
209
+ toolResult(
210
+ part,
211
+ part.state === "output-error" ? "error-json" : "normal",
212
+ ),
213
+ );
214
+ }
215
+ }
216
+ }
217
+ if (assistantContent.length > 0) {
218
+ messages.push({
219
+ message: { role: "assistant", content: assistantContent },
220
+ sources,
221
+ });
222
+ }
223
+
224
+ const toolContent: ToolContentPart[] = [];
225
+ for (const part of tools) {
226
+ if (part.approval?.approved !== undefined) {
227
+ toolContent.push({
228
+ type: "tool-approval-response",
229
+ approvalId: part.approval.id,
230
+ approved: part.approval.approved,
231
+ reason: part.approval.reason,
232
+ providerExecuted: part.providerExecuted,
233
+ });
234
+ }
235
+ if (
236
+ part.state === "approval-responded" &&
237
+ part.approval?.approved === false
238
+ ) {
239
+ toolContent.push(
240
+ toolResult(part, "execution-denied", part.approval.reason),
241
+ );
242
+ }
243
+ if (part.providerExecuted === true) continue;
244
+ if (part.state === "output-denied") {
245
+ toolContent.push(
246
+ toolResult(part, "execution-denied", part.approval?.reason),
247
+ );
248
+ } else if (part.state === "output-error") {
249
+ toolContent.push(toolResult(part, "error-text"));
250
+ } else if (part.state === "output-available") {
251
+ toolContent.push(toolResult(part, "normal"));
252
+ }
253
+ }
254
+ if (toolContent.length > 0) {
255
+ messages.push({
256
+ message: { role: "tool", content: toolContent },
257
+ sources: [],
258
+ });
259
+ }
260
+ }
261
+
262
+ return messages.map(({ message, sources }) => {
263
+ const content = Array.isArray(message.content) ? message.content : [];
264
+ const providerMetadataValue = content.find(
265
+ (part) => part.providerOptions !== undefined,
266
+ )?.providerOptions;
267
+ const hasToolCall =
268
+ message.role === "tool" ||
269
+ content.some((part) => part.type === "tool-call");
270
+ return {
271
+ message,
272
+ status: metadata.status,
273
+ finishReason: hasToolCall ? "tool-calls" : "stop",
274
+ model: stream.model,
275
+ provider: stream.provider,
276
+ ...(providerMetadataValue
277
+ ? { providerMetadata: providerMetadataValue }
278
+ : {}),
279
+ sources,
280
+ reasoning: content
281
+ .filter(
282
+ (part): part is Extract<MessageContentParts, { type: "reasoning" }> =>
283
+ part.type === "reasoning",
284
+ )
285
+ .map((part) => part.text)
286
+ .join(" "),
287
+ ...(metadata.error !== undefined ? { error: metadata.error } : {}),
288
+ } satisfies MessageWithMetadataInternal;
289
+ });
290
+ }
291
+
292
+ function projectSources(parts: PersistedUIMessagePart[]) {
293
+ return parts
294
+ .filter((part): part is PersistedSourcePart =>
295
+ ["source-url", "source-document"].includes(part.type),
296
+ )
297
+ .map((part) =>
298
+ part.type === "source-url"
299
+ ? {
300
+ type: "source" as const,
301
+ sourceType: "url" as const,
302
+ url: part.url,
303
+ id: part.sourceId,
304
+ providerMetadata: part.providerMetadata,
305
+ title: part.title,
306
+ }
307
+ : {
308
+ type: "source" as const,
309
+ sourceType: "document" as const,
310
+ mediaType: part.mediaType,
311
+ id: part.sourceId,
312
+ providerMetadata: part.providerMetadata,
313
+ title: part.title,
314
+ filename: part.filename,
315
+ },
316
+ );
317
+ }
318
+
319
+ function toolResult(
320
+ part: PersistedToolPart,
321
+ mode: "normal" | "error-text" | "error-json" | "execution-denied",
322
+ deniedReason?: string,
323
+ ): Extract<MessageContentParts, { type: "tool-result" }> {
324
+ const raw =
325
+ mode === "execution-denied"
326
+ ? deniedReason
327
+ : part.state === "output-error"
328
+ ? part.errorText
329
+ : part.output;
330
+ const output =
331
+ mode === "execution-denied"
332
+ ? {
333
+ type: "execution-denied" as const,
334
+ ...(deniedReason !== undefined ? { reason: deniedReason } : {}),
335
+ }
336
+ : mode === "error-text"
337
+ ? { type: "error-text" as const, value: String(raw) }
338
+ : mode === "error-json"
339
+ ? { type: "error-json" as const, value: raw ?? null }
340
+ : validate(vToolResultOutput, raw)
341
+ ? (raw as Extract<
342
+ MessageContentParts,
343
+ { type: "tool-result" }
344
+ >["output"])
345
+ : typeof raw === "string"
346
+ ? { type: "text" as const, value: raw }
347
+ : { type: "json" as const, value: raw ?? null };
348
+ const providerOptions =
349
+ part.resultProviderMetadata ?? part.callProviderMetadata;
350
+ return {
351
+ type: "tool-result",
352
+ toolCallId: part.toolCallId,
353
+ toolName: getToolName(part),
354
+ output,
355
+ ...(providerOptions ? { providerOptions } : {}),
356
+ };
357
+ }
358
+
359
+ function isToolPart(part: PersistedUIMessagePart): part is PersistedToolPart {
360
+ return part.type === "dynamic-tool" || part.type.startsWith("tool-");
361
+ }
362
+
363
+ function getToolName(part: PersistedToolPart): string {
364
+ if (part.type === "dynamic-tool") {
365
+ if (!part.toolName) throw new Error("Dynamic tool part is missing toolName");
366
+ return part.toolName;
367
+ }
368
+ // AI SDK's UI wire format encodes static tools as `tool-${name}`.
369
+ if (!part.type.startsWith("tool-")) {
370
+ throw new Error(`Invalid static tool part type "${part.type}"`);
371
+ }
372
+ return part.type.slice("tool-".length);
373
+ }
374
+
375
+ function parseCompleteJson(value: string): unknown {
376
+ try {
377
+ return JSON.parse(value);
378
+ } catch {
379
+ return undefined;
380
+ }
381
+ }
382
+
383
+ export function persistedProjectionError(error: unknown): Error {
384
+ if (!(error instanceof PersistedUIMessageChunkError)) {
385
+ return error instanceof Error ? error : new Error(String(error));
386
+ }
387
+ if (error.code === "missing-approval") {
388
+ return new Error(
389
+ `Persisted tool-output-denied for tool call "${error.id}" requires a preceding approval request.`,
390
+ );
391
+ }
392
+ if (error.code === "unsupported-chunk") {
393
+ return new Error(`unsupported durable chunk type "${error.chunkType}"`);
394
+ }
395
+ return new Error(
396
+ `Received ${error.chunkType} for missing ${error.partType} part with ID "${error.id}".`,
397
+ );
398
+ }
@@ -0,0 +1,205 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ createPersistedUIMessageChunkState,
4
+ reducePersistedUIMessageChunks,
5
+ } from "./persistedUIMessageChunks.js";
6
+
7
+ describe("reducePersistedUIMessageChunks", () => {
8
+ it("carries durable reducer state across persisted batches", () => {
9
+ const first = reducePersistedUIMessageChunks(
10
+ createPersistedUIMessageChunkState(),
11
+ [
12
+ { type: "text-start", id: "text-1" },
13
+ { type: "text-delta", id: "text-1", delta: "partial" },
14
+ { type: "tool-input-start", toolCallId: "tool-1", toolName: "lookup" },
15
+ {
16
+ type: "tool-input-delta",
17
+ toolCallId: "tool-1",
18
+ inputTextDelta: '{"query":',
19
+ },
20
+ ],
21
+ );
22
+ const second = reducePersistedUIMessageChunks(first.state, [
23
+ { type: "text-delta", id: "text-1", delta: " answer" },
24
+ { type: "text-end", id: "text-1" },
25
+ {
26
+ type: "tool-input-delta",
27
+ toolCallId: "tool-1",
28
+ inputTextDelta: '"ok"}',
29
+ },
30
+ ]);
31
+
32
+ expect(second.state.parts).toMatchObject([
33
+ { type: "text", text: "partial answer", state: "done" },
34
+ { type: "tool-lookup", toolCallId: "tool-1", state: "input-streaming" },
35
+ ]);
36
+ expect(second.state.toolInputText.get("tool-1")).toBe('{"query":"ok"}');
37
+ expect(second.touchedToolCallIds).toEqual(["tool-1"]);
38
+ });
39
+
40
+ it("accepts metadata-only lifecycle chunks without materializing parts", () => {
41
+ const reduced = reducePersistedUIMessageChunks(
42
+ createPersistedUIMessageChunkState(),
43
+ [
44
+ { type: "start", messageMetadata: { source: "start" } },
45
+ { type: "message-metadata", messageMetadata: { source: "update" } },
46
+ { type: "finish", messageMetadata: { source: "finish" } },
47
+ ],
48
+ );
49
+
50
+ expect(reduced.state.parts).toEqual([]);
51
+ });
52
+
53
+ it("accepts the UIMessageChunk superset with replacement metadata", () => {
54
+ const reduced = reducePersistedUIMessageChunks(
55
+ createPersistedUIMessageChunkState(),
56
+ [
57
+ { type: "custom", kind: "openai.annotation" },
58
+ {
59
+ type: "reasoning-file",
60
+ url: "https://example.com/reasoning.pdf",
61
+ mediaType: "application/pdf",
62
+ },
63
+ {
64
+ type: "text-start",
65
+ id: "text-1",
66
+ providerMetadata: {
67
+ openai: { phase: "start", stale: true },
68
+ anthropic: { stale: true },
69
+ },
70
+ },
71
+ {
72
+ type: "text-delta",
73
+ id: "text-1",
74
+ delta: "answer",
75
+ providerMetadata: { anthropic: { phase: "delta" } },
76
+ },
77
+ { type: "text-end", id: "text-1" },
78
+ {
79
+ type: "tool-input-start",
80
+ toolCallId: "call-1",
81
+ toolName: "lookup",
82
+ title: "Old title",
83
+ providerMetadata: { openai: { phase: "call" } },
84
+ },
85
+ {
86
+ type: "tool-input-error",
87
+ toolCallId: "call-1",
88
+ toolName: "lookup",
89
+ input: "bad",
90
+ errorText: "invalid",
91
+ title: "New title",
92
+ providerMetadata: { openai: { phase: "result" } },
93
+ },
94
+ ],
95
+ );
96
+
97
+ expect(reduced.state.parts).toStrictEqual([
98
+ {
99
+ type: "custom",
100
+ kind: "openai.annotation",
101
+ providerMetadata: undefined,
102
+ },
103
+ {
104
+ type: "reasoning-file",
105
+ url: "https://example.com/reasoning.pdf",
106
+ mediaType: "application/pdf",
107
+ providerMetadata: undefined,
108
+ },
109
+ {
110
+ type: "text",
111
+ text: "answer",
112
+ state: "done",
113
+ providerMetadata: { anthropic: { phase: "delta" } },
114
+ },
115
+ {
116
+ type: "tool-lookup",
117
+ toolName: undefined,
118
+ toolCallId: "call-1",
119
+ state: "output-error",
120
+ input: undefined,
121
+ rawInput: "bad",
122
+ errorText: "invalid",
123
+ providerExecuted: undefined,
124
+ callProviderMetadata: { openai: { phase: "call" } },
125
+ resultProviderMetadata: { openai: { phase: "result" } },
126
+ title: "New title",
127
+ toolMetadata: undefined,
128
+ },
129
+ ]);
130
+ });
131
+
132
+ it("clears a preliminary tool output when its final result is an error", () => {
133
+ const reduced = reducePersistedUIMessageChunks(
134
+ createPersistedUIMessageChunkState(),
135
+ [
136
+ {
137
+ type: "tool-input-available",
138
+ toolCallId: "call-1",
139
+ toolName: "lookup",
140
+ input: {},
141
+ },
142
+ {
143
+ type: "tool-output-available",
144
+ toolCallId: "call-1",
145
+ output: { partial: true },
146
+ preliminary: true,
147
+ },
148
+ {
149
+ type: "tool-output-error",
150
+ toolCallId: "call-1",
151
+ errorText: "final failure",
152
+ },
153
+ ],
154
+ );
155
+
156
+ expect(reduced.state.parts).toMatchObject([
157
+ {
158
+ type: "tool-lookup",
159
+ state: "output-error",
160
+ errorText: "final failure",
161
+ },
162
+ ]);
163
+ const part = reduced.state.parts[0] as {
164
+ output?: unknown;
165
+ preliminary?: boolean;
166
+ };
167
+ expect(part.output).toBeUndefined();
168
+ expect(part.preliminary).toBeUndefined();
169
+ });
170
+
171
+ it("requires boolean approval responses and stops at orphan continuations", () => {
172
+ const approvalPrefix = [
173
+ {
174
+ type: "tool-input-available",
175
+ toolCallId: "call-1",
176
+ toolName: "lookup",
177
+ input: {},
178
+ },
179
+ {
180
+ type: "tool-approval-request",
181
+ toolCallId: "call-1",
182
+ approvalId: "approval-1",
183
+ },
184
+ ];
185
+ expect(() =>
186
+ reducePersistedUIMessageChunks(createPersistedUIMessageChunkState(), [
187
+ ...approvalPrefix,
188
+ {
189
+ type: "tool-approval-response",
190
+ approvalId: "approval-1",
191
+ approved: "yes",
192
+ },
193
+ ]),
194
+ ).toThrow("field approved must be a boolean");
195
+
196
+ const orphaned = reducePersistedUIMessageChunks(
197
+ createPersistedUIMessageChunkState(),
198
+ [
199
+ { type: "tool-output-available", toolCallId: "orphan", output: 1 },
200
+ { type: "text-start", id: "unreachable" },
201
+ ],
202
+ );
203
+ expect(orphaned.state.parts).toEqual([]);
204
+ });
205
+ });