@convex-dev/agent 0.6.3 → 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 (246) 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/apiKeys.js +5 -5
  11. package/dist/component/apiKeys.js.map +1 -1
  12. package/dist/component/files.d.ts.map +1 -1
  13. package/dist/component/files.js +13 -11
  14. package/dist/component/files.js.map +1 -1
  15. package/dist/component/messages.d.ts +1252 -201
  16. package/dist/component/messages.d.ts.map +1 -1
  17. package/dist/component/messages.js +83 -47
  18. package/dist/component/messages.js.map +1 -1
  19. package/dist/component/schema.d.ts +2593 -332
  20. package/dist/component/schema.d.ts.map +1 -1
  21. package/dist/component/streams.d.ts +7 -1
  22. package/dist/component/streams.d.ts.map +1 -1
  23. package/dist/component/streams.js +46 -47
  24. package/dist/component/streams.js.map +1 -1
  25. package/dist/component/threads.d.ts.map +1 -1
  26. package/dist/component/threads.js +8 -9
  27. package/dist/component/threads.js.map +1 -1
  28. package/dist/component/users.js +2 -2
  29. package/dist/component/users.js.map +1 -1
  30. package/dist/component/vector/index.d.ts.map +1 -1
  31. package/dist/component/vector/index.js +14 -8
  32. package/dist/component/vector/index.js.map +1 -1
  33. package/dist/react/index.d.ts +5 -5
  34. package/dist/react/index.d.ts.map +1 -1
  35. package/dist/react/index.js +5 -5
  36. package/dist/react/index.js.map +1 -1
  37. package/dist/shared.d.ts +19 -7
  38. package/dist/shared.d.ts.map +1 -1
  39. package/dist/shared.js +4 -4
  40. package/dist/shared.js.map +1 -1
  41. package/dist/streaming/materializePersistedUIMessageChunks.d.ts +16 -0
  42. package/dist/streaming/materializePersistedUIMessageChunks.d.ts.map +1 -0
  43. package/dist/streaming/materializePersistedUIMessageChunks.js +302 -0
  44. package/dist/streaming/materializePersistedUIMessageChunks.js.map +1 -0
  45. package/dist/streaming/persistedUIMessageChunks.d.ts +106 -0
  46. package/dist/streaming/persistedUIMessageChunks.d.ts.map +1 -0
  47. package/dist/streaming/persistedUIMessageChunks.js +475 -0
  48. package/dist/streaming/persistedUIMessageChunks.js.map +1 -0
  49. package/dist/validators.d.ts +10026 -1055
  50. package/dist/validators.d.ts.map +1 -1
  51. package/dist/validators.js +71 -9
  52. package/dist/validators.js.map +1 -1
  53. package/dist/{UIMessages.d.ts → vercel/UIMessages.d.ts} +1 -1
  54. package/dist/vercel/UIMessages.d.ts.map +1 -0
  55. package/dist/{UIMessages.js → vercel/UIMessages.js} +63 -1
  56. package/dist/vercel/UIMessages.js.map +1 -0
  57. package/dist/{client → vercel/client}/createTool.d.ts +23 -16
  58. package/dist/vercel/client/createTool.d.ts.map +1 -0
  59. package/dist/{client → vercel/client}/createTool.js +2 -0
  60. package/dist/vercel/client/createTool.js.map +1 -0
  61. package/dist/{client → vercel/client}/definePlaygroundAPI.d.ts +593 -134
  62. package/dist/{client → vercel/client}/definePlaygroundAPI.d.ts.map +1 -1
  63. package/dist/{client → vercel/client}/definePlaygroundAPI.js +13 -9
  64. package/dist/vercel/client/definePlaygroundAPI.js.map +1 -0
  65. package/dist/{client → vercel/client}/files.d.ts +2 -2
  66. package/dist/vercel/client/files.d.ts.map +1 -0
  67. package/dist/vercel/client/files.js.map +1 -0
  68. package/dist/vercel/client/messages.d.ts +542 -0
  69. package/dist/vercel/client/messages.d.ts.map +1 -0
  70. package/dist/vercel/client/messages.js +66 -0
  71. package/dist/vercel/client/messages.js.map +1 -0
  72. package/dist/{client → vercel/client}/mockModel.d.ts +17 -17
  73. package/dist/vercel/client/mockModel.d.ts.map +1 -0
  74. package/dist/{client → vercel/client}/mockModel.js +12 -13
  75. package/dist/vercel/client/mockModel.js.map +1 -0
  76. package/dist/{client → vercel/client}/saveInputMessages.d.ts +2 -2
  77. package/dist/vercel/client/saveInputMessages.d.ts.map +1 -0
  78. package/dist/vercel/client/saveInputMessages.js.map +1 -0
  79. package/dist/{client → vercel/client}/search.d.ts +119 -22
  80. package/dist/vercel/client/search.d.ts.map +1 -0
  81. package/dist/{client → vercel/client}/search.js +2 -2
  82. package/dist/vercel/client/search.js.map +1 -0
  83. package/dist/{client → vercel/client}/start.d.ts +26 -11
  84. package/dist/vercel/client/start.d.ts.map +1 -0
  85. package/dist/{client → vercel/client}/start.js +64 -22
  86. package/dist/vercel/client/start.js.map +1 -0
  87. package/dist/{client → vercel/client}/streamText.d.ts +13 -12
  88. package/dist/vercel/client/streamText.d.ts.map +1 -0
  89. package/dist/{client → vercel/client}/streamText.js +43 -9
  90. package/dist/vercel/client/streamText.js.map +1 -0
  91. package/dist/{client → vercel/client}/streaming.d.ts +1410 -183
  92. package/dist/vercel/client/streaming.d.ts.map +1 -0
  93. package/dist/{client → vercel/client}/streaming.js +120 -52
  94. package/dist/vercel/client/streaming.js.map +1 -0
  95. package/dist/vercel/client/types.d.ts +456 -0
  96. package/dist/vercel/client/types.d.ts.map +1 -0
  97. package/dist/vercel/client/types.js +3 -0
  98. package/dist/vercel/client/types.js.map +1 -0
  99. package/dist/{client → vercel/client}/utils.d.ts +3 -2
  100. package/dist/vercel/client/utils.d.ts.map +1 -0
  101. package/dist/{client → vercel/client}/utils.js +2 -2
  102. package/dist/vercel/client/utils.js.map +1 -0
  103. package/dist/vercel/deltas.d.ts +31 -0
  104. package/dist/vercel/deltas.d.ts.map +1 -0
  105. package/dist/vercel/deltas.js +525 -0
  106. package/dist/vercel/deltas.js.map +1 -0
  107. package/dist/{client → vercel}/index.d.ts +484 -97
  108. package/dist/vercel/index.d.ts.map +1 -0
  109. package/dist/{client → vercel}/index.js +65 -38
  110. package/dist/vercel/index.js.map +1 -0
  111. package/dist/{mapping.d.ts → vercel/mapping.d.ts} +5 -13
  112. package/dist/vercel/mapping.d.ts.map +1 -0
  113. package/dist/{mapping.js → vercel/mapping.js} +411 -112
  114. package/dist/vercel/mapping.js.map +1 -0
  115. package/dist/{react → vercel/react}/optimisticallySendMessage.d.ts +2 -2
  116. package/dist/vercel/react/optimisticallySendMessage.d.ts.map +1 -0
  117. package/dist/vercel/react/optimisticallySendMessage.js.map +1 -0
  118. package/dist/{react → vercel/react}/types.d.ts +1 -1
  119. package/dist/vercel/react/types.d.ts.map +1 -0
  120. package/dist/{react → vercel/react}/types.js.map +1 -1
  121. package/dist/{react → vercel/react}/useDeltaStreams.d.ts +1 -1
  122. package/dist/vercel/react/useDeltaStreams.d.ts.map +1 -0
  123. package/dist/{react → vercel/react}/useDeltaStreams.js +1 -1
  124. package/dist/vercel/react/useDeltaStreams.js.map +1 -0
  125. package/dist/{react → vercel/react}/useStreamingUIMessages.d.ts +1 -1
  126. package/dist/vercel/react/useStreamingUIMessages.d.ts.map +1 -0
  127. package/dist/vercel/react/useStreamingUIMessages.js +112 -0
  128. package/dist/vercel/react/useStreamingUIMessages.js.map +1 -0
  129. package/dist/{react → vercel/react}/useThreadMessages.d.ts +1 -1
  130. package/dist/vercel/react/useThreadMessages.d.ts.map +1 -0
  131. package/dist/{react → vercel/react}/useThreadMessages.js +1 -1
  132. package/dist/vercel/react/useThreadMessages.js.map +1 -0
  133. package/dist/{react → vercel/react}/useUIMessages.d.ts +2 -1
  134. package/dist/vercel/react/useUIMessages.d.ts.map +1 -0
  135. package/dist/{react → vercel/react}/useUIMessages.js +8 -4
  136. package/dist/vercel/react/useUIMessages.js.map +1 -0
  137. package/package.json +30 -22
  138. package/src/client/messages.ts +5 -107
  139. package/src/client/types.ts +0 -621
  140. package/src/component/_generated/component.ts +1089 -48
  141. package/src/component/apiKeys.ts +5 -5
  142. package/src/component/files.test.ts +1 -1
  143. package/src/component/files.ts +14 -12
  144. package/src/component/messages.test.ts +273 -1
  145. package/src/component/messages.ts +99 -57
  146. package/src/component/streams.ts +80 -64
  147. package/src/component/threads.ts +12 -9
  148. package/src/component/users.ts +2 -2
  149. package/src/component/vector/index.ts +14 -7
  150. package/src/eslintConfig.test.ts +44 -0
  151. package/src/react/index.ts +8 -5
  152. package/src/shared.ts +26 -36
  153. package/src/streaming/materializePersistedUIMessageChunks.test.ts +513 -0
  154. package/src/streaming/materializePersistedUIMessageChunks.ts +398 -0
  155. package/src/streaming/persistedUIMessageChunks.test.ts +205 -0
  156. package/src/streaming/persistedUIMessageChunks.ts +658 -0
  157. package/src/validators.test.ts +4 -1
  158. package/src/validators.ts +89 -8
  159. package/src/{UIMessages.combineUIMessages.test.ts → vercel/UIMessages.combineUIMessages.test.ts} +4 -1
  160. package/src/{UIMessages.ts → vercel/UIMessages.ts} +96 -5
  161. package/src/{client → vercel/client}/approval.test.ts +48 -19
  162. package/src/{client → vercel/client}/createTool.ts +63 -23
  163. package/src/{client → vercel/client}/definePlaygroundAPI.ts +44 -29
  164. package/src/{client → vercel/client}/files.ts +2 -2
  165. package/src/{client → vercel/client}/index.test.ts +162 -3
  166. package/src/vercel/client/messages.ts +182 -0
  167. package/src/{client → vercel/client}/mockModel.ts +38 -39
  168. package/src/{client → vercel/client}/saveInputMessages.test.ts +1 -1
  169. package/src/{client → vercel/client}/saveInputMessages.ts +7 -4
  170. package/src/{client → vercel/client}/search.test.ts +3 -3
  171. package/src/{client → vercel/client}/search.ts +4 -4
  172. package/src/{client → vercel/client}/setup.test.ts +6 -3
  173. package/src/vercel/client/start.test.ts +14 -0
  174. package/src/{client → vercel/client}/start.ts +147 -36
  175. package/src/vercel/client/streamText.test.ts +223 -0
  176. package/src/{client → vercel/client}/streamText.ts +102 -31
  177. package/src/{client → vercel/client}/streaming.integration.test.ts +40 -115
  178. package/src/{client → vercel/client}/streaming.test.ts +178 -4
  179. package/src/{client → vercel/client}/streaming.ts +114 -52
  180. package/src/vercel/client/types.test.ts +13 -0
  181. package/src/vercel/client/types.ts +679 -0
  182. package/src/{client → vercel/client}/utils.ts +13 -7
  183. package/src/vercel/deltas.test.ts +898 -0
  184. package/src/vercel/deltas.ts +633 -0
  185. package/src/{fromUIMessages.test.ts → vercel/fromUIMessages.test.ts} +17 -1
  186. package/src/{client → vercel}/index.ts +169 -62
  187. package/src/vercel/mapping.test.ts +965 -0
  188. package/src/{mapping.ts → vercel/mapping.ts} +501 -131
  189. package/src/{react → vercel/react}/optimisticallySendMessage.ts +2 -2
  190. package/src/{react → vercel/react}/types.ts +1 -1
  191. package/src/{react → vercel/react}/useDeltaStreams.ts +6 -2
  192. package/src/{react → vercel/react}/useStreamingUIMessages.ts +67 -35
  193. package/src/{react → vercel/react}/useThreadMessages.ts +2 -2
  194. package/src/{react → vercel/react}/useUIMessages.test.ts +80 -1
  195. package/src/{react → vercel/react}/useUIMessages.ts +13 -5
  196. package/src/{toUIMessages.test.ts → vercel/toUIMessages.test.ts} +49 -2
  197. package/dist/UIMessages.d.ts.map +0 -1
  198. package/dist/UIMessages.js.map +0 -1
  199. package/dist/client/createTool.d.ts.map +0 -1
  200. package/dist/client/createTool.js.map +0 -1
  201. package/dist/client/definePlaygroundAPI.js.map +0 -1
  202. package/dist/client/files.d.ts.map +0 -1
  203. package/dist/client/files.js.map +0 -1
  204. package/dist/client/index.d.ts.map +0 -1
  205. package/dist/client/index.js.map +0 -1
  206. package/dist/client/mockModel.d.ts.map +0 -1
  207. package/dist/client/mockModel.js.map +0 -1
  208. package/dist/client/saveInputMessages.d.ts.map +0 -1
  209. package/dist/client/saveInputMessages.js.map +0 -1
  210. package/dist/client/search.d.ts.map +0 -1
  211. package/dist/client/search.js.map +0 -1
  212. package/dist/client/start.d.ts.map +0 -1
  213. package/dist/client/start.js.map +0 -1
  214. package/dist/client/streamText.d.ts.map +0 -1
  215. package/dist/client/streamText.js.map +0 -1
  216. package/dist/client/streaming.d.ts.map +0 -1
  217. package/dist/client/streaming.js.map +0 -1
  218. package/dist/client/utils.d.ts.map +0 -1
  219. package/dist/client/utils.js.map +0 -1
  220. package/dist/deltas.d.ts +0 -43
  221. package/dist/deltas.d.ts.map +0 -1
  222. package/dist/deltas.js +0 -446
  223. package/dist/deltas.js.map +0 -1
  224. package/dist/mapping.d.ts.map +0 -1
  225. package/dist/mapping.js.map +0 -1
  226. package/dist/react/optimisticallySendMessage.d.ts.map +0 -1
  227. package/dist/react/optimisticallySendMessage.js.map +0 -1
  228. package/dist/react/types.d.ts.map +0 -1
  229. package/dist/react/useDeltaStreams.d.ts.map +0 -1
  230. package/dist/react/useDeltaStreams.js.map +0 -1
  231. package/dist/react/useStreamingUIMessages.d.ts.map +0 -1
  232. package/dist/react/useStreamingUIMessages.js +0 -92
  233. package/dist/react/useStreamingUIMessages.js.map +0 -1
  234. package/dist/react/useThreadMessages.d.ts.map +0 -1
  235. package/dist/react/useThreadMessages.js.map +0 -1
  236. package/dist/react/useUIMessages.d.ts.map +0 -1
  237. package/dist/react/useUIMessages.js.map +0 -1
  238. package/src/client/streamText.test.ts +0 -102
  239. package/src/deltas.test.ts +0 -626
  240. package/src/deltas.ts +0 -569
  241. package/src/mapping.test.ts +0 -402
  242. /package/dist/{client → vercel/client}/files.js +0 -0
  243. /package/dist/{client → vercel/client}/saveInputMessages.js +0 -0
  244. /package/dist/{react → vercel/react}/optimisticallySendMessage.js +0 -0
  245. /package/dist/{react → vercel/react}/types.js +0 -0
  246. /package/src/{UIMessages.test.ts → vercel/UIMessages.test.ts} +0 -0
@@ -0,0 +1,898 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ applyUIMessageChunksIncremental,
4
+ blankUIMessage,
5
+ emptyIncrementalStreamState,
6
+ getParts,
7
+ } from "./deltas.js";
8
+ import type { StreamDelta } from "../validators.js";
9
+ import type { ToolUIPart, UIMessageChunk } from "ai";
10
+
11
+ describe("UIMessageChunks", () => {
12
+ it("updates a UIMessage with a tool call and follow up", async () => {
13
+ const uiMessage = blankUIMessage(
14
+ {
15
+ streamId: "s1",
16
+ status: "streaming",
17
+ order: 0,
18
+ stepOrder: 1,
19
+ format: "UIMessageChunk",
20
+ agentName: "agent1",
21
+ },
22
+ "thread1",
23
+ );
24
+ expect(uiMessage.text).toBe("");
25
+ expect(uiMessage.parts).toEqual([]);
26
+ const updatedMessage = applyUIMessageChunksIncremental(
27
+ uiMessage,
28
+ [
29
+ { type: "start" },
30
+ { type: "start-step" },
31
+ { type: "reasoning-start", id: "reasoning-0" },
32
+ { type: "reasoning-delta", id: "reasoning-0", delta: "Okay" },
33
+ {
34
+ type: "reasoning-delta",
35
+ id: "reasoning-0",
36
+ delta: ", the user is asking...",
37
+ },
38
+ { type: "text-start", id: "txt-1" },
39
+ {
40
+ type: "text-delta",
41
+ id: "txt-1",
42
+ delta: "Hey ho.",
43
+ },
44
+ { type: "reasoning-end", id: "reasoning-0" },
45
+ { type: "text-end", id: "txt-1" },
46
+ { type: "tool-input-start", toolCallId: "0ychh9k6f", toolName: "say" },
47
+ {
48
+ type: "tool-input-delta",
49
+ toolCallId: "0ychh9k6f",
50
+ inputTextDelta:
51
+ '{"question":"What is your favorite flavor of ice cream?"}',
52
+ },
53
+ {
54
+ type: "tool-input-available",
55
+ toolCallId: "0ychh9k6f",
56
+ toolName: "say",
57
+ input: { question: "What is your favorite flavor of ice cream?" },
58
+ providerMetadata: { openai: { itemId: "123" } },
59
+ },
60
+ {
61
+ type: "tool-output-available",
62
+ toolCallId: "0ychh9k6f",
63
+ output: "I'm sorry I can't help you. Stop asking me questions.",
64
+ },
65
+ { type: "finish-step" },
66
+ { type: "start-step" },
67
+ { type: "tool-input-start", toolCallId: "1ychh9k6f", toolName: "say" },
68
+ {
69
+ type: "tool-input-delta",
70
+ toolCallId: "1ychh9k6f",
71
+ inputTextDelta:
72
+ '{"question":"What is your favorite flavor of ice cream?"}',
73
+ },
74
+ {
75
+ type: "tool-input-available",
76
+ toolCallId: "1ychh9k6f",
77
+ toolName: "say",
78
+ input: { question: "What is your favorite flavor of ice cream?" },
79
+ },
80
+ {
81
+ type: "tool-output-available",
82
+ toolCallId: "1ychh9k6f",
83
+ output: "I'm serious.",
84
+ },
85
+ { type: "finish-step" },
86
+ { type: "start-step" },
87
+ { type: "text-start", id: "msg_0" },
88
+ {
89
+ type: "text-delta",
90
+ id: "msg_0",
91
+ delta: "The best ice cream flavor is vanilla",
92
+ },
93
+ {
94
+ type: "text-delta",
95
+ id: "msg_0",
96
+ delta: ".",
97
+ },
98
+ { type: "text-end", id: "msg_0" },
99
+ { type: "finish-step" },
100
+ { type: "finish" },
101
+ ],
102
+ emptyIncrementalStreamState(),
103
+ ).message;
104
+ expect(updatedMessage.text).toBe(
105
+ "Hey ho. The best ice cream flavor is vanilla.",
106
+ );
107
+ const expectedParts = [
108
+ {
109
+ type: "step-start",
110
+ },
111
+ {
112
+ state: "done",
113
+ text: "Okay, the user is asking...",
114
+ type: "reasoning",
115
+ },
116
+ {
117
+ state: "done",
118
+ text: "Hey ho.",
119
+ type: "text",
120
+ },
121
+ {
122
+ callProviderMetadata: {
123
+ openai: {
124
+ itemId: "123",
125
+ },
126
+ },
127
+ input: {
128
+ question: "What is your favorite flavor of ice cream?",
129
+ },
130
+ output: "I'm sorry I can't help you. Stop asking me questions.",
131
+
132
+ state: "output-available",
133
+ toolCallId: "0ychh9k6f",
134
+ type: "tool-say",
135
+ },
136
+ {
137
+ type: "step-start",
138
+ },
139
+ {
140
+ input: {
141
+ question: "What is your favorite flavor of ice cream?",
142
+ },
143
+ output: "I'm serious.",
144
+ state: "output-available",
145
+ toolCallId: "1ychh9k6f",
146
+ type: "tool-say",
147
+ },
148
+ {
149
+ type: "step-start",
150
+ },
151
+ {
152
+ state: "done",
153
+ text: "The best ice cream flavor is vanilla.",
154
+ type: "text",
155
+ },
156
+ ];
157
+ expect(updatedMessage.parts).toEqual(expectedParts);
158
+ expect(updatedMessage.parts).toHaveLength(8);
159
+ });
160
+ });
161
+
162
+ describe("UIMessageChunks - continuation stream", () => {
163
+ it("gracefully handles tool-result without tool-call in continuation stream after approval", async () => {
164
+ // This simulates what happens after tool approval:
165
+ // Stream A: tool-call, tool-approval-request -> finishes
166
+ // User approves
167
+ // Stream B: tool-result (referencing tool-call from Stream A) -> this test
168
+ //
169
+ // The continuation stream does not repeat the prior tool call. The reducer
170
+ // tolerates the orphan result because stored messages provide that context.
171
+ const uiMessage = blankUIMessage(
172
+ {
173
+ streamId: "continuation-stream",
174
+ status: "streaming",
175
+ order: 1,
176
+ stepOrder: 0,
177
+ format: "UIMessageChunk",
178
+ agentName: "agent1",
179
+ },
180
+ "thread1",
181
+ );
182
+
183
+ // Send a tool-result without the corresponding tool-call in this stream
184
+ // This would normally throw "No tool invocation found" error
185
+ const updatedMessage = applyUIMessageChunksIncremental(
186
+ uiMessage,
187
+ [
188
+ { type: "start" },
189
+ { type: "start-step" },
190
+ {
191
+ type: "tool-output-available",
192
+ toolCallId: "call_from_previous_stream",
193
+ output: "Tool execution result",
194
+ },
195
+ { type: "finish-step" },
196
+ { type: "finish" },
197
+ ],
198
+ emptyIncrementalStreamState(),
199
+ ).message;
200
+
201
+ // The message should NOT be marked as failed - the error should be suppressed
202
+ expect(updatedMessage.status).not.toBe("failed");
203
+ // The stream still processes (even if tool-output isn't reflected without tool-input)
204
+ expect(updatedMessage).toBeDefined();
205
+ });
206
+ });
207
+
208
+ describe("mergeDeltas", () => {
209
+ it("incremental apply only consumes parts past the cursor (no re-processing)", () => {
210
+ const N = 500;
211
+ const streamId = "s-perf";
212
+ const toolCallId = "tool-0";
213
+ const streamMessage = {
214
+ streamId,
215
+ status: "streaming" as const,
216
+ order: 0,
217
+ stepOrder: 0,
218
+ format: "UIMessageChunk" as const,
219
+ agentName: "agent1",
220
+ };
221
+
222
+ // One StreamDelta with preamble, then N deltas each with one tool-input-delta
223
+ const allDeltas: StreamDelta[] = [
224
+ {
225
+ streamId,
226
+ start: 0,
227
+ end: 1,
228
+ parts: [
229
+ { type: "start" },
230
+ { type: "start-step" },
231
+ { type: "tool-input-start", toolCallId, toolName: "myTool" },
232
+ ] as UIMessageChunk[],
233
+ },
234
+ ...Array.from({ length: N }, (_, i) => ({
235
+ streamId,
236
+ start: i + 1,
237
+ end: i + 2,
238
+ parts: [
239
+ {
240
+ type: "tool-input-delta",
241
+ toolCallId,
242
+ inputTextDelta: "x",
243
+ } as UIMessageChunk,
244
+ ],
245
+ })),
246
+ ];
247
+
248
+ // Simulate the hook: process one delta at a time, tracking cursor + prior message
249
+ let cursor = 0;
250
+ let uiMessage = blankUIMessage(streamMessage, "thread-perf");
251
+ let streamState = emptyIncrementalStreamState();
252
+ let totalPartsProcessed = 0;
253
+
254
+ for (let i = 0; i <= N; i++) {
255
+ const available = allDeltas.slice(0, i + 1);
256
+ const { parts: newParts, cursor: newCursor } = getParts<UIMessageChunk>(
257
+ available,
258
+ cursor,
259
+ );
260
+ if (newParts.length > 0) {
261
+ totalPartsProcessed += newParts.length;
262
+ ({ message: uiMessage, streamState } = applyUIMessageChunksIncremental(
263
+ structuredClone(uiMessage),
264
+ newParts,
265
+ streamState,
266
+ ));
267
+ cursor = newCursor;
268
+ }
269
+ }
270
+
271
+ // Each delta part is handed to applyUIMessageChunksIncremental exactly
272
+ // once across all batches (cursor slicing — no re-processing of prior
273
+ // parts). N tool-input-deltas + 3 preamble parts. The end-to-end O(N)
274
+ // claim is proven by the PR's 21,000 ms → 73 ms benchmark, not by this
275
+ // unit test.
276
+ expect(totalPartsProcessed).toBe(N + 3);
277
+
278
+ // Correctness: the raw accumulator holds "x" repeated N times across batches
279
+ expect(streamState.toolInputText[toolCallId]).toBe("x".repeat(N));
280
+ const toolPart = uiMessage.parts.find(
281
+ (p): p is ToolUIPart => "toolCallId" in p && p.toolCallId === toolCallId,
282
+ );
283
+ expect(toolPart).toBeDefined();
284
+ });
285
+
286
+ it("applyUIMessageChunksIncremental: text-delta accumulation across calls", () => {
287
+ const streamMessage = {
288
+ streamId: "s-text",
289
+ status: "streaming" as const,
290
+ order: 0,
291
+ stepOrder: 0,
292
+ format: "UIMessageChunk" as const,
293
+ agentName: "a",
294
+ };
295
+ let msg = blankUIMessage(streamMessage, "thread-text");
296
+ let state = emptyIncrementalStreamState();
297
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
298
+ msg,
299
+ [
300
+ { type: "start" },
301
+ { type: "start-step" },
302
+ { type: "text-start", id: "t0" },
303
+ { type: "text-delta", id: "t0", delta: "Hello " },
304
+ ] as UIMessageChunk[],
305
+ state,
306
+ ));
307
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
308
+ msg,
309
+ [{ type: "text-delta", id: "t0", delta: "world" }] as UIMessageChunk[],
310
+ state,
311
+ ));
312
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
313
+ msg,
314
+ [
315
+ { type: "text-delta", id: "t0", delta: "!" },
316
+ { type: "text-end", id: "t0" },
317
+ ] as UIMessageChunk[],
318
+ state,
319
+ ));
320
+
321
+ const textPart = msg.parts.find((p) => p.type === "text") as
322
+ | { text: string; state: string }
323
+ | undefined;
324
+ expect(textPart?.text).toBe("Hello world!");
325
+ expect(textPart?.state).toBe("done");
326
+ expect(msg.text).toBe("Hello world!");
327
+ });
328
+
329
+ it("applyUIMessageChunksIncremental: tool-output-available preserves input and sets fields", async () => {
330
+ const streamMessage = {
331
+ streamId: "s-tool-out",
332
+ status: "streaming" as const,
333
+ order: 0,
334
+ stepOrder: 0,
335
+ format: "UIMessageChunk" as const,
336
+ agentName: "a",
337
+ };
338
+ let msg = blankUIMessage(streamMessage, "thread-tool-out");
339
+ let state = emptyIncrementalStreamState();
340
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
341
+ msg,
342
+ [
343
+ { type: "start" },
344
+ { type: "start-step" },
345
+ { type: "tool-input-start", toolCallId: "c1", toolName: "myTool" },
346
+ {
347
+ type: "tool-input-available",
348
+ toolCallId: "c1",
349
+ toolName: "myTool",
350
+ input: { q: "hi" },
351
+ },
352
+ ] as UIMessageChunk[],
353
+ state,
354
+ ));
355
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
356
+ msg,
357
+ [
358
+ {
359
+ type: "tool-output-available",
360
+ toolCallId: "c1",
361
+ output: { result: "ok" },
362
+ preliminary: true,
363
+ providerExecuted: true,
364
+ },
365
+ ] as UIMessageChunk[],
366
+ state,
367
+ ));
368
+
369
+ const toolPart = msg.parts.find(
370
+ (p): p is ToolUIPart => "toolCallId" in p && p.toolCallId === "c1",
371
+ );
372
+ expect(toolPart?.state).toBe("output-available");
373
+ expect(toolPart?.input).toEqual({ q: "hi" });
374
+ expect((toolPart as { output?: unknown }).output).toEqual({ result: "ok" });
375
+ expect((toolPart as { preliminary?: boolean }).preliminary).toBe(true);
376
+ expect((toolPart as { providerExecuted?: boolean }).providerExecuted).toBe(
377
+ true,
378
+ );
379
+
380
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
381
+ msg,
382
+ [
383
+ {
384
+ type: "tool-output-available",
385
+ toolCallId: "c1",
386
+ output: { result: "final" },
387
+ },
388
+ ] as UIMessageChunk[],
389
+ state,
390
+ ));
391
+ const completedToolPart = msg.parts.find(
392
+ (p): p is ToolUIPart => "toolCallId" in p && p.toolCallId === "c1",
393
+ );
394
+ expect(
395
+ (completedToolPart as { preliminary?: boolean }).preliminary,
396
+ ).toBeUndefined();
397
+
398
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
399
+ msg,
400
+ [
401
+ {
402
+ type: "tool-output-error",
403
+ toolCallId: "c1",
404
+ errorText: "final failure",
405
+ },
406
+ ] as UIMessageChunk[],
407
+ state,
408
+ ));
409
+
410
+ const failedToolPart = msg.parts.find(
411
+ (p): p is ToolUIPart => "toolCallId" in p && p.toolCallId === "c1",
412
+ );
413
+ expect(failedToolPart).toMatchObject({ state: "output-error" });
414
+ expect((failedToolPart as { output?: unknown }).output).toBeUndefined();
415
+ expect(
416
+ (failedToolPart as { preliminary?: boolean }).preliminary,
417
+ ).toBeUndefined();
418
+ });
419
+
420
+ it("keeps a denied approval distinct from its later denied output", () => {
421
+ const streamMessage = {
422
+ streamId: "s-tool-approval",
423
+ status: "streaming" as const,
424
+ order: 0,
425
+ stepOrder: 0,
426
+ format: "UIMessageChunk" as const,
427
+ agentName: "a",
428
+ };
429
+ let message = blankUIMessage(streamMessage, "thread-tool-approval");
430
+ let streamState = emptyIncrementalStreamState();
431
+ ({ message, streamState } = applyUIMessageChunksIncremental(
432
+ message,
433
+ [
434
+ {
435
+ type: "tool-input-start",
436
+ toolCallId: "call-1",
437
+ toolName: "deleteFile",
438
+ providerExecuted: true,
439
+ providerMetadata: { openai: { request: "call-1" } },
440
+ },
441
+ {
442
+ type: "tool-input-available",
443
+ toolCallId: "call-1",
444
+ toolName: "deleteFile",
445
+ input: { path: "/tmp/file" },
446
+ },
447
+ {
448
+ type: "tool-approval-request",
449
+ toolCallId: "call-1",
450
+ approvalId: "approval-1",
451
+ },
452
+ {
453
+ type: "tool-approval-response",
454
+ approvalId: "approval-1",
455
+ approved: false,
456
+ reason: "User declined",
457
+ },
458
+ ] as UIMessageChunk[],
459
+ streamState,
460
+ ));
461
+
462
+ expect(message.parts).toContainEqual(
463
+ expect.objectContaining({
464
+ toolCallId: "call-1",
465
+ state: "approval-responded",
466
+ approval: {
467
+ id: "approval-1",
468
+ approved: false,
469
+ reason: "User declined",
470
+ },
471
+ providerExecuted: true,
472
+ callProviderMetadata: { openai: { request: "call-1" } },
473
+ }),
474
+ );
475
+
476
+ ({ message, streamState } = applyUIMessageChunksIncremental(
477
+ message,
478
+ [{ type: "tool-output-denied", toolCallId: "call-1" }],
479
+ streamState,
480
+ ));
481
+
482
+ expect(message.parts).toContainEqual(
483
+ expect.objectContaining({
484
+ toolCallId: "call-1",
485
+ state: "output-denied",
486
+ }),
487
+ );
488
+ });
489
+
490
+ it("matches SDK 7 provider metadata replacement and dynamic tool initialization", () => {
491
+ const streamMessage = {
492
+ streamId: "s-metadata",
493
+ status: "streaming" as const,
494
+ order: 0,
495
+ stepOrder: 0,
496
+ format: "UIMessageChunk" as const,
497
+ agentName: "a",
498
+ };
499
+ const { message } = applyUIMessageChunksIncremental(
500
+ blankUIMessage(streamMessage, "thread-metadata"),
501
+ [
502
+ {
503
+ type: "text-start",
504
+ id: "t",
505
+ providerMetadata: { openai: { first: true } },
506
+ },
507
+ {
508
+ type: "text-delta",
509
+ id: "t",
510
+ delta: "x",
511
+ providerMetadata: { openai: { second: true } },
512
+ },
513
+ {
514
+ type: "tool-input-start",
515
+ toolCallId: "dynamic-1",
516
+ toolName: "runtimeTool",
517
+ dynamic: true,
518
+ providerExecuted: true,
519
+ title: "Runtime tool",
520
+ toolMetadata: { source: "provider" },
521
+ providerMetadata: { openai: { itemId: "item-1" } },
522
+ },
523
+ ] as UIMessageChunk[],
524
+ emptyIncrementalStreamState(),
525
+ );
526
+
527
+ expect(message.parts.find((part) => part.type === "text")).toMatchObject({
528
+ providerMetadata: { openai: { second: true } },
529
+ });
530
+ expect(
531
+ message.parts.find(
532
+ (part) => "toolCallId" in part && part.toolCallId === "dynamic-1",
533
+ ),
534
+ ).toMatchObject({
535
+ type: "dynamic-tool",
536
+ providerExecuted: true,
537
+ title: "Runtime tool",
538
+ toolMetadata: { source: "provider" },
539
+ callProviderMetadata: { openai: { itemId: "item-1" } },
540
+ });
541
+ });
542
+
543
+ it("creates tool parts from standalone tool-input-available chunks", () => {
544
+ const { message } = applyUIMessageChunksIncremental(
545
+ blankUIMessage(
546
+ {
547
+ streamId: "s-available",
548
+ status: "streaming",
549
+ order: 0,
550
+ stepOrder: 0,
551
+ format: "UIMessageChunk",
552
+ agentName: "a",
553
+ },
554
+ "thread-available",
555
+ ),
556
+ [
557
+ {
558
+ type: "tool-input-available",
559
+ toolCallId: "static-call",
560
+ toolName: "weather",
561
+ input: { city: "Boston" },
562
+ providerExecuted: true,
563
+ },
564
+ {
565
+ type: "tool-input-available",
566
+ toolCallId: "dynamic-call",
567
+ toolName: "runtimeTool",
568
+ dynamic: true,
569
+ input: { id: 1 },
570
+ },
571
+ ] as UIMessageChunk[],
572
+ emptyIncrementalStreamState(),
573
+ );
574
+
575
+ expect(message.parts).toMatchObject([
576
+ {
577
+ type: "tool-weather",
578
+ toolCallId: "static-call",
579
+ state: "input-available",
580
+ input: { city: "Boston" },
581
+ providerExecuted: true,
582
+ },
583
+ {
584
+ type: "dynamic-tool",
585
+ toolCallId: "dynamic-call",
586
+ toolName: "runtimeTool",
587
+ state: "input-available",
588
+ input: { id: 1 },
589
+ },
590
+ ]);
591
+ });
592
+
593
+ it("creates output-error parts from standalone tool-input-error chunks", () => {
594
+ const { message } = applyUIMessageChunksIncremental(
595
+ blankUIMessage(
596
+ {
597
+ streamId: "s-error",
598
+ status: "streaming",
599
+ order: 0,
600
+ stepOrder: 0,
601
+ format: "UIMessageChunk",
602
+ agentName: "a",
603
+ },
604
+ "thread-error",
605
+ ),
606
+ [
607
+ {
608
+ type: "tool-input-error",
609
+ toolCallId: "static-error",
610
+ toolName: "weather",
611
+ input: { city: "Boston" },
612
+ errorText: "Invalid weather request",
613
+ providerExecuted: true,
614
+ providerMetadata: { openai: { result: "static" } },
615
+ },
616
+ {
617
+ type: "tool-input-error",
618
+ toolCallId: "dynamic-error",
619
+ toolName: "runtimeTool",
620
+ dynamic: true,
621
+ input: { id: 1 },
622
+ errorText: "Invalid runtime request",
623
+ providerMetadata: { anthropic: { result: "dynamic" } },
624
+ },
625
+ ] as UIMessageChunk[],
626
+ emptyIncrementalStreamState(),
627
+ );
628
+
629
+ expect(message.parts).toMatchObject([
630
+ {
631
+ type: "tool-weather",
632
+ toolCallId: "static-error",
633
+ state: "output-error",
634
+ rawInput: { city: "Boston" },
635
+ errorText: "Invalid weather request",
636
+ providerExecuted: true,
637
+ resultProviderMetadata: { openai: { result: "static" } },
638
+ },
639
+ {
640
+ type: "dynamic-tool",
641
+ toolCallId: "dynamic-error",
642
+ toolName: "runtimeTool",
643
+ state: "output-error",
644
+ input: { id: 1 },
645
+ errorText: "Invalid runtime request",
646
+ resultProviderMetadata: { anthropic: { result: "dynamic" } },
647
+ },
648
+ ]);
649
+ });
650
+
651
+ it("applyUIMessageChunksIncremental: tool-input-error sets rawInput and clears input for static tools", async () => {
652
+ const streamMessage = {
653
+ streamId: "s-tool-err",
654
+ status: "streaming" as const,
655
+ order: 0,
656
+ stepOrder: 0,
657
+ format: "UIMessageChunk" as const,
658
+ agentName: "a",
659
+ };
660
+ let msg = blankUIMessage(streamMessage, "thread-tool-err");
661
+ let state = emptyIncrementalStreamState();
662
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
663
+ msg,
664
+ [
665
+ { type: "start" },
666
+ { type: "start-step" },
667
+ {
668
+ type: "tool-input-start",
669
+ toolCallId: "c2",
670
+ toolName: "myTool",
671
+ providerExecuted: true,
672
+ title: "My tool",
673
+ toolMetadata: { source: "test" },
674
+ providerMetadata: { openai: { itemId: "call-1" } },
675
+ },
676
+ ] as UIMessageChunk[],
677
+ state,
678
+ ));
679
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
680
+ msg,
681
+ [
682
+ {
683
+ type: "tool-input-error",
684
+ toolCallId: "c2",
685
+ toolName: "myTool",
686
+ input: { bad: "args" },
687
+ errorText: "validation failed",
688
+ },
689
+ ] as UIMessageChunk[],
690
+ state,
691
+ ));
692
+
693
+ const toolPart = msg.parts.find(
694
+ (p): p is ToolUIPart => "toolCallId" in p && p.toolCallId === "c2",
695
+ );
696
+ expect(toolPart?.state).toBe("output-error");
697
+ expect((toolPart as { errorText?: string }).errorText).toBe(
698
+ "validation failed",
699
+ );
700
+ expect(toolPart?.input).toBeUndefined();
701
+ expect((toolPart as { rawInput?: unknown }).rawInput).toEqual({
702
+ bad: "args",
703
+ });
704
+ expect(toolPart).toMatchObject({
705
+ providerExecuted: true,
706
+ title: "My tool",
707
+ toolMetadata: { source: "test" },
708
+ callProviderMetadata: { openai: { itemId: "call-1" } },
709
+ });
710
+ });
711
+
712
+ it("accumulates tool input across a batch boundary", async () => {
713
+ const streamMessage = {
714
+ streamId: "s-tool-split",
715
+ status: "streaming" as const,
716
+ order: 0,
717
+ stepOrder: 0,
718
+ format: "UIMessageChunk" as const,
719
+ agentName: "a",
720
+ };
721
+ let msg = blankUIMessage(streamMessage, "thread-tool-split");
722
+ let state = emptyIncrementalStreamState();
723
+
724
+ // Batch A: preamble + the first half of the JSON input.
725
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
726
+ msg,
727
+ [
728
+ { type: "start" },
729
+ { type: "start-step" },
730
+ { type: "tool-input-start", toolCallId: "c1", toolName: "myTool" },
731
+ {
732
+ type: "tool-input-delta",
733
+ toolCallId: "c1",
734
+ inputTextDelta: '{"a":1',
735
+ },
736
+ ] as UIMessageChunk[],
737
+ state,
738
+ ));
739
+ const afterA = msg.parts.find(
740
+ (p): p is ToolUIPart => "toolCallId" in p && p.toolCallId === "c1",
741
+ );
742
+ // Mid-stream: JSON is incomplete, input stays unset.
743
+ expect(afterA?.input).toBeUndefined();
744
+
745
+ // Batch B: the remainder of the JSON input.
746
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
747
+ msg,
748
+ [
749
+ {
750
+ type: "tool-input-delta",
751
+ toolCallId: "c1",
752
+ inputTextDelta: ',"b":2}',
753
+ },
754
+ ] as UIMessageChunk[],
755
+ state,
756
+ ));
757
+ const afterB = msg.parts.find(
758
+ (p): p is ToolUIPart => "toolCallId" in p && p.toolCallId === "c1",
759
+ );
760
+ // Complete JSON is parsed once the accumulator is valid.
761
+ expect(afterB?.input).toEqual({ a: 1, b: 2 });
762
+ expect(state.toolInputText["c1"]).toBe('{"a":1,"b":2}');
763
+ });
764
+
765
+ it("pushes file parts and merges message metadata in later batches", async () => {
766
+ const streamMessage = {
767
+ streamId: "s-file-meta",
768
+ status: "streaming" as const,
769
+ order: 0,
770
+ stepOrder: 0,
771
+ format: "UIMessageChunk" as const,
772
+ agentName: "a",
773
+ };
774
+ let msg = blankUIMessage(streamMessage, "thread-file-meta");
775
+ let state = emptyIncrementalStreamState();
776
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
777
+ msg,
778
+ [{ type: "start" }, { type: "start-step" }] as UIMessageChunk[],
779
+ state,
780
+ ));
781
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
782
+ msg,
783
+ [
784
+ {
785
+ type: "file",
786
+ mediaType: "image/png",
787
+ url: "https://example.com/a.png",
788
+ },
789
+ { type: "message-metadata", messageMetadata: { foo: "bar" } },
790
+ ] as UIMessageChunk[],
791
+ state,
792
+ ));
793
+
794
+ const filePart = msg.parts.find((p) => p.type === "file") as
795
+ | { mediaType: string; url: string }
796
+ | undefined;
797
+ expect(filePart?.mediaType).toBe("image/png");
798
+ expect(filePart?.url).toBe("https://example.com/a.png");
799
+ expect(msg.metadata).toEqual({ foo: "bar" });
800
+ });
801
+
802
+ it("tracks concurrent text parts by id across batches", async () => {
803
+ const streamMessage = {
804
+ streamId: "s-multi-text",
805
+ status: "streaming" as const,
806
+ order: 0,
807
+ stepOrder: 0,
808
+ format: "UIMessageChunk" as const,
809
+ agentName: "a",
810
+ };
811
+ let msg = blankUIMessage(streamMessage, "thread-multi-text");
812
+ let state = emptyIncrementalStreamState();
813
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
814
+ msg,
815
+ [
816
+ { type: "start" },
817
+ { type: "start-step" },
818
+ { type: "text-start", id: "t0" },
819
+ { type: "text-start", id: "t1" },
820
+ { type: "text-delta", id: "t0", delta: "A" },
821
+ ] as UIMessageChunk[],
822
+ state,
823
+ ));
824
+ // Deltas in a later batch must land on the part matching their id.
825
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
826
+ msg,
827
+ [
828
+ { type: "text-delta", id: "t1", delta: "B" },
829
+ { type: "text-delta", id: "t0", delta: "C" },
830
+ ] as UIMessageChunk[],
831
+ state,
832
+ ));
833
+
834
+ const textParts = msg.parts.filter((p) => p.type === "text") as Array<{
835
+ text: string;
836
+ }>;
837
+ expect(textParts.map((p) => p.text)).toEqual(["AC", "B"]);
838
+ });
839
+
840
+ it("incremental batches match the SDK processing the full stream", async () => {
841
+ const streamMessage = {
842
+ streamId: "s-equiv",
843
+ status: "streaming" as const,
844
+ order: 0,
845
+ stepOrder: 0,
846
+ format: "UIMessageChunk" as const,
847
+ agentName: "a",
848
+ };
849
+ const batches: UIMessageChunk[][] = [
850
+ [
851
+ { type: "start" },
852
+ { type: "start-step" },
853
+ { type: "text-start", id: "t0" },
854
+ { type: "text-delta", id: "t0", delta: "Hello " },
855
+ ] as UIMessageChunk[],
856
+ [
857
+ { type: "text-delta", id: "t0", delta: "world" },
858
+ { type: "text-end", id: "t0" },
859
+ { type: "tool-input-start", toolCallId: "c1", toolName: "myTool" },
860
+ { type: "tool-input-delta", toolCallId: "c1", inputTextDelta: '{"q":' },
861
+ ] as UIMessageChunk[],
862
+ [
863
+ { type: "tool-input-delta", toolCallId: "c1", inputTextDelta: '"hi"}' },
864
+ {
865
+ type: "tool-input-available",
866
+ toolCallId: "c1",
867
+ toolName: "myTool",
868
+ input: { q: "hi" },
869
+ },
870
+ {
871
+ type: "tool-output-available",
872
+ toolCallId: "c1",
873
+ output: { ok: true },
874
+ },
875
+ { type: "finish-step" },
876
+ { type: "finish" },
877
+ ] as UIMessageChunk[],
878
+ ];
879
+
880
+ // SDK: process the entire stream at once.
881
+ const sdkMsg = applyUIMessageChunksIncremental(
882
+ blankUIMessage(streamMessage, "thread-equiv"),
883
+ batches.flat(),
884
+ emptyIncrementalStreamState(),
885
+ ).message;
886
+
887
+ // Incremental: process batch by batch, threading state.
888
+ let incMsg = blankUIMessage(streamMessage, "thread-equiv");
889
+ let state = emptyIncrementalStreamState();
890
+ for (const batch of batches) {
891
+ ({ message: incMsg, streamState: state } =
892
+ applyUIMessageChunksIncremental(incMsg, batch, state));
893
+ }
894
+
895
+ expect(incMsg.parts).toEqual(sdkMsg.parts);
896
+ expect(incMsg.text).toBe(sdkMsg.text);
897
+ });
898
+ });